Merge pull request #52 from jlowin/pyright

Add pyright + tests
This commit is contained in:
Jeremiah Lowin 2024-12-03 14:02:20 -05:00 committed by GitHub
commit 5ddec50e73
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 378 additions and 291 deletions

View file

@ -28,3 +28,9 @@ jobs:
python-version: "3.12"
- name: Run pre-commit
uses: pre-commit/action@v3.0.1
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install ".[tests]"
- name: Run pyright
run: pyright src tests

View file

@ -25,6 +25,7 @@ build-backend = "hatchling.build"
[project.optional-dependencies]
tests = [
"pre-commit",
"pyright>=1.1.389",
"pytest>=8.3.3",
"pytest-asyncio>=0.23.5",
"pytest-flakefinder",
@ -39,3 +40,15 @@ asyncio_default_fixture_loop_scope = "session"
[tool.hatch.version]
source = "vcs"
[tool.pyright]
include = ["src", "tests"]
exclude = ["**/node_modules", "**/__pycache__", ".venv", ".git", "dist"]
pythonVersion = "3.10"
pythonPlatform = "Darwin"
typeCheckingMode = "basic"
reportMissingImports = true
reportMissingTypeStubs = false
useLibraryCodeForTypes = true
venvPath = "."
venv = ".venv"

View file

@ -2,6 +2,7 @@
import importlib.metadata
import importlib.util
import os
import subprocess
import sys
from pathlib import Path
@ -242,6 +243,7 @@ def dev(
[npx_cmd, "@modelcontextprotocol/inspector"] + uv_cmd,
check=True,
shell=shell,
env=dict(os.environ.items()), # Convert to list of tuples for env update
)
sys.exit(process.returncode)
except subprocess.CalledProcessError as e:
@ -423,7 +425,10 @@ def install(
# Load from .env file if specified
if env_file:
try:
env_dict.update(dotenv.dotenv_values(env_file))
env_values = dotenv.dotenv_values(env_file)
env_dict.update(
(k, str(v)) for k, v in env_values.items() if v is not None
)
except Exception as e:
logger.error(f"Failed to load .env file: {e}")
sys.exit(1)

View file

@ -1,43 +1,52 @@
"""Base classes for FastMCP prompts."""
import json
from typing import Any, Callable, Dict, Literal, Optional, Sequence, Union
from typing import Any, Callable, Dict, Literal, Optional, Sequence, Awaitable
import inspect
from pydantic import BaseModel, Field, TypeAdapter, field_validator, validate_call
from pydantic import BaseModel, Field, TypeAdapter, validate_call
from mcp.types import TextContent, ImageContent, EmbeddedResource
import pydantic_core
CONTENT_TYPES = TextContent | ImageContent | EmbeddedResource
class Message(BaseModel):
"""Base class for all prompt messages."""
role: Literal["user", "assistant"]
content: Union[TextContent, ImageContent, EmbeddedResource]
content: CONTENT_TYPES
def __init__(self, content, **kwargs):
def __init__(self, content: str | CONTENT_TYPES, **kwargs):
if isinstance(content, str):
content = TextContent(type="text", text=content)
super().__init__(content=content, **kwargs)
@field_validator("content", mode="before")
def validate_content(cls, v):
if isinstance(v, str):
return TextContent(type="text", text=v)
return v
class UserMessage(Message):
"""A message from the user."""
role: Literal["user"] = "user"
def __init__(self, content: str | CONTENT_TYPES, **kwargs):
super().__init__(content=content, **kwargs)
class AssistantMessage(Message):
"""A message from the assistant."""
role: Literal["assistant"] = "assistant"
def __init__(self, content: str | CONTENT_TYPES, **kwargs):
super().__init__(content=content, **kwargs)
message_validator = TypeAdapter(Union[UserMessage, AssistantMessage])
message_validator = TypeAdapter(UserMessage | AssistantMessage)
SyncPromptResult = (
str | Message | dict[str, Any] | Sequence[str | Message | dict[str, Any]]
)
PromptResult = SyncPromptResult | Awaitable[SyncPromptResult]
class PromptArgument(BaseModel):
@ -67,11 +76,18 @@ class Prompt(BaseModel):
@classmethod
def from_function(
cls,
fn: Callable[..., Sequence[Message]],
fn: Callable[..., PromptResult],
name: Optional[str] = None,
description: Optional[str] = None,
) -> "Prompt":
"""Create a Prompt from a function."""
"""Create a Prompt from a function.
The function can return:
- A string (converted to a message)
- A Message object
- A dict (converted to a message)
- A sequence of any of the above
"""
func_name = name or fn.__name__
if func_name == "<lambda>":

View file

@ -1,14 +1,14 @@
"""Base classes and interfaces for FastMCP resources."""
import abc
from typing import Union
from typing import Union, Annotated
from pydantic import (
AnyUrl,
BaseModel,
ConfigDict,
Field,
FileUrl,
UrlConstraints,
ValidationInfo,
field_validator,
)
@ -19,8 +19,9 @@ class Resource(BaseModel, abc.ABC):
model_config = ConfigDict(validate_default=True)
# uri: Annotated[AnyUrl, BeforeValidator(maybe_cast_str_to_any_url)] = Field(
uri: AnyUrl = Field(default=..., description="URI of the resource")
uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] = Field(
default=..., description="URI of the resource"
)
name: str | None = Field(description="Name of the resource", default=None)
description: str | None = Field(
description="Description of the resource", default=None
@ -31,15 +32,6 @@ class Resource(BaseModel, abc.ABC):
pattern=r"^[a-zA-Z0-9]+/[a-zA-Z0-9\-+.]+$",
)
@field_validator("uri", mode="before")
def validate_uri(cls, uri: AnyUrl | str) -> AnyUrl:
if isinstance(uri, str):
# AnyUrl doesn't support triple-slashes, but files do ("file:///absolute/path")
if uri.startswith("file://"):
return FileUrl(uri)
return AnyUrl(uri)
return uri
@field_validator("name", mode="before")
@classmethod
def set_default_name(cls, name: str | None, info: ValidationInfo) -> str:

View file

@ -70,7 +70,7 @@ class ResourceTemplate(BaseModel):
result = await result
return FunctionResource(
uri=uri,
uri=uri, # type: ignore
name=self.name,
description=self.description,
mime_type=self.mime_type,

View file

@ -8,7 +8,7 @@ from typing import Any, Callable, Union
import httpx
import pydantic.json
import pydantic_core
from pydantic import Field
from pydantic import Field, ValidationInfo
from fastmcp.resources.base import Resource
@ -91,6 +91,15 @@ class FileResource(Resource):
raise ValueError("Path must be absolute")
return path
@pydantic.field_validator("is_binary")
@classmethod
def set_binary_from_mime_type(cls, is_binary: bool, info: ValidationInfo) -> bool:
"""Set is_binary based on mime_type if not explicitly set."""
if is_binary:
return True
mime_type = info.data.get("mime_type", "text/plain")
return not mime_type.startswith("text/")
async def read(self) -> Union[str, bytes]:
"""Read the file content."""
try:

View file

@ -23,6 +23,7 @@ from mcp.types import (
)
from mcp.types import (
Prompt as MCPPrompt,
PromptArgument as MCPPromptArgument,
)
from mcp.types import (
Resource as MCPResource,
@ -159,7 +160,7 @@ class FastMCP:
async def call_tool(
self, name: str, arguments: dict
) -> Sequence[TextContent | ImageContent]:
) -> Sequence[TextContent | ImageContent | EmbeddedResource]:
"""Call a tool by name with arguments."""
context = self.get_context()
result = await self._tool_manager.call_tool(name, arguments, context=context)
@ -462,11 +463,11 @@ class FastMCP:
name=prompt.name,
description=prompt.description,
arguments=[
{
"name": arg.name,
"description": arg.description,
"required": arg.required,
}
MCPPromptArgument(
name=arg.name,
description=arg.description,
required=arg.required,
)
for arg in (prompt.arguments or [])
],
)

View file

@ -47,7 +47,7 @@ class FuncMetadata(BaseModel):
async def call_fn_with_arg_validation(
self,
fn: Callable | Awaitable,
fn: Callable[..., Any] | Awaitable[Any],
fn_is_async: bool,
arguments_to_validate: dict[str, Any],
arguments_to_pass_directly: dict[str, Any] | None,
@ -64,8 +64,12 @@ class FuncMetadata(BaseModel):
arguments_parsed_dict |= arguments_to_pass_directly or {}
if fn_is_async:
if isinstance(fn, Awaitable):
return await fn
return await fn(**arguments_parsed_dict)
return fn(**arguments_parsed_dict)
if isinstance(fn, Callable):
return fn(**arguments_parsed_dict)
raise TypeError("fn must be either Callable or Awaitable")
def pre_parse_json(self, data: dict[str, Any]) -> dict[str, Any]:
"""Pre-parse data from JSON.
@ -123,6 +127,7 @@ def func_metadata(func: Callable, skip_names: Sequence[str] = ()) -> FuncMetadat
sig = _get_typed_signature(func)
params = sig.parameters
dynamic_pydantic_model_params: dict[str, Any] = {}
globalns = getattr(func, "__globals__", {})
for param in params.values():
if param.name.startswith("_"):
raise InvalidSignature(
@ -153,7 +158,7 @@ def func_metadata(func: Callable, skip_names: Sequence[str] = ()) -> FuncMetadat
]
field_info = FieldInfo.from_annotated_attribute(
annotation,
_get_typed_annotation(annotation, globalns),
param.default
if param.default is not inspect.Parameter.empty
else PydanticUndefined,

View file

@ -47,7 +47,9 @@ class Image:
if self.path:
with open(self.path, "rb") as f:
data = base64.b64encode(f.read()).decode()
else:
elif self.data is not None:
data = base64.b64encode(self.data).decode()
else:
raise ValueError("No image data available")
return ImageContent(type="image", data=data, mimeType=self._mime_type)

View file

@ -1,3 +1,4 @@
from pydantic import FileUrl
import pytest
from fastmcp.prompts.base import (
Prompt,
@ -102,7 +103,7 @@ class TestRenderPrompt:
content=EmbeddedResource(
type="resource",
resource=TextResourceContents(
uri="file://file.txt",
uri=FileUrl("file://file.txt"),
text="File contents",
mimeType="text/plain",
),
@ -115,7 +116,7 @@ class TestRenderPrompt:
content=EmbeddedResource(
type="resource",
resource=TextResourceContents(
uri="file://file.txt",
uri=FileUrl("file://file.txt"),
text="File contents",
mimeType="text/plain",
),
@ -133,7 +134,7 @@ class TestRenderPrompt:
content=EmbeddedResource(
type="resource",
resource=TextResourceContents(
uri="file://file.txt",
uri=FileUrl("file://file.txt"),
text="File contents",
mimeType="text/plain",
),
@ -151,7 +152,7 @@ class TestRenderPrompt:
content=EmbeddedResource(
type="resource",
resource=TextResourceContents(
uri="file://file.txt",
uri=FileUrl("file://file.txt"),
text="File contents",
mimeType="text/plain",
),
@ -171,7 +172,7 @@ class TestRenderPrompt:
"content": {
"type": "resource",
"resource": {
"uri": "file://file.txt",
"uri": FileUrl("file://file.txt"),
"text": "File contents",
"mimeType": "text/plain",
},
@ -184,7 +185,7 @@ class TestRenderPrompt:
content=EmbeddedResource(
type="resource",
resource=TextResourceContents(
uri="file://file.txt",
uri=FileUrl("file://file.txt"),
text="File contents",
mimeType="text/plain",
),

View file

@ -3,6 +3,7 @@ import os
import pytest
from pathlib import Path
from tempfile import NamedTemporaryFile
from pydantic import FileUrl
from fastmcp.resources import FileResource
@ -30,7 +31,7 @@ class TestFileResource:
def test_file_resource_creation(self, temp_file: Path):
"""Test creating a FileResource."""
resource = FileResource(
uri=temp_file.as_uri(),
uri=FileUrl(temp_file.as_uri()),
name="test",
description="test file",
path=temp_file,
@ -45,9 +46,9 @@ class TestFileResource:
def test_file_resource_str_path_conversion(self, temp_file: Path):
"""Test FileResource handles string paths."""
resource = FileResource(
uri=f"file://{temp_file}",
uri=FileUrl(f"file://{temp_file}"),
name="test",
path=str(temp_file),
path=Path(str(temp_file)),
)
assert isinstance(resource.path, Path)
assert resource.path.is_absolute()
@ -55,7 +56,7 @@ class TestFileResource:
async def test_read_text_file(self, temp_file: Path):
"""Test reading a text file."""
resource = FileResource(
uri=f"file://{temp_file}",
uri=FileUrl(f"file://{temp_file}"),
name="test",
path=temp_file,
)
@ -66,7 +67,7 @@ class TestFileResource:
async def test_read_binary_file(self, temp_file: Path):
"""Test reading a file as binary."""
resource = FileResource(
uri=f"file://{temp_file}",
uri=FileUrl(f"file://{temp_file}"),
name="test",
path=temp_file,
is_binary=True,
@ -79,7 +80,7 @@ class TestFileResource:
"""Test error on relative path."""
with pytest.raises(ValueError, match="Path must be absolute"):
FileResource(
uri="file:///test.txt",
uri=FileUrl("file:///test.txt"),
name="test",
path=Path("test.txt"),
)
@ -89,7 +90,7 @@ class TestFileResource:
# Create path to non-existent file
missing = temp_file.parent / "missing.txt"
resource = FileResource(
uri="file:///missing.txt",
uri=FileUrl("file:///missing.txt"),
name="test",
path=missing,
)
@ -104,7 +105,7 @@ class TestFileResource:
temp_file.chmod(0o000) # Remove all permissions
try:
resource = FileResource(
uri=temp_file.as_uri(),
uri=FileUrl(temp_file.as_uri()),
name="test",
path=temp_file,
)

View file

@ -1,4 +1,4 @@
from pydantic import BaseModel
from pydantic import BaseModel, AnyUrl
import pytest
from fastmcp.resources import FunctionResource
@ -13,7 +13,7 @@ class TestFunctionResource:
return "test content"
resource = FunctionResource(
uri="fn://test",
uri=AnyUrl("fn://test"),
name="test",
description="test function",
fn=my_func,
@ -31,7 +31,7 @@ class TestFunctionResource:
return "Hello, world!"
resource = FunctionResource(
uri="function://test",
uri=AnyUrl("function://test"),
name="test",
fn=get_data,
)
@ -46,7 +46,7 @@ class TestFunctionResource:
return b"Hello, world!"
resource = FunctionResource(
uri="function://test",
uri=AnyUrl("function://test"),
name="test",
fn=get_data,
)
@ -60,11 +60,12 @@ class TestFunctionResource:
return {"key": "value"}
resource = FunctionResource(
uri="function://test",
uri=AnyUrl("function://test"),
name="test",
fn=get_data,
)
content = await resource.read()
assert isinstance(content, str)
assert '"key": "value"' in content
async def test_error_handling(self):
@ -74,7 +75,7 @@ class TestFunctionResource:
raise ValueError("Test error")
resource = FunctionResource(
uri="function://test",
uri=AnyUrl("function://test"),
name="test",
fn=failing_func,
)
@ -88,7 +89,7 @@ class TestFunctionResource:
name: str
resource = FunctionResource(
uri="function://test",
uri=AnyUrl("function://test"),
name="test",
fn=lambda: MyModel(name="test"),
)
@ -106,7 +107,7 @@ class TestFunctionResource:
return CustomData()
resource = FunctionResource(
uri="function://test",
uri=AnyUrl("function://test"),
name="test",
fn=get_data,
)

View file

@ -1,6 +1,7 @@
import pytest
from pathlib import Path
from tempfile import NamedTemporaryFile
from pydantic import AnyUrl, FileUrl
from fastmcp.resources import (
FileResource,
@ -34,7 +35,7 @@ class TestResourceManager:
"""Test adding a resource."""
manager = ResourceManager()
resource = FileResource(
uri=f"file://{temp_file}",
uri=FileUrl(f"file://{temp_file}"),
name="test",
path=temp_file,
)
@ -46,7 +47,7 @@ class TestResourceManager:
"""Test adding the same resource twice."""
manager = ResourceManager()
resource = FileResource(
uri=f"file://{temp_file}",
uri=FileUrl(f"file://{temp_file}"),
name="test",
path=temp_file,
)
@ -59,7 +60,7 @@ class TestResourceManager:
"""Test warning on duplicate resources."""
manager = ResourceManager()
resource = FileResource(
uri=f"file://{temp_file}",
uri=FileUrl(f"file://{temp_file}"),
name="test",
path=temp_file,
)
@ -71,7 +72,7 @@ class TestResourceManager:
"""Test disabling warning on duplicate resources."""
manager = ResourceManager(warn_on_duplicate_resources=False)
resource = FileResource(
uri=f"file://{temp_file}",
uri=FileUrl(f"file://{temp_file}"),
name="test",
path=temp_file,
)
@ -83,7 +84,7 @@ class TestResourceManager:
"""Test getting a resource by URI."""
manager = ResourceManager()
resource = FileResource(
uri=f"file://{temp_file}",
uri=FileUrl(f"file://{temp_file}"),
name="test",
path=temp_file,
)
@ -105,7 +106,7 @@ class TestResourceManager:
)
manager._templates[template.uri_template] = template
resource = await manager.get_resource("greet://world")
resource = await manager.get_resource(AnyUrl("greet://world"))
assert isinstance(resource, FunctionResource)
content = await resource.read()
assert content == "Hello, world!"
@ -114,18 +115,18 @@ class TestResourceManager:
"""Test getting a non-existent resource."""
manager = ResourceManager()
with pytest.raises(ValueError, match="Unknown resource"):
await manager.get_resource("unknown://test")
await manager.get_resource(AnyUrl("unknown://test"))
def test_list_resources(self, temp_file: Path):
"""Test listing all resources."""
manager = ResourceManager()
resource1 = FileResource(
uri=f"file://{temp_file}",
uri=FileUrl(f"file://{temp_file}"),
name="test1",
path=temp_file,
)
resource2 = FileResource(
uri=f"file://{temp_file}2",
uri=FileUrl(f"file://{temp_file}2"),
name="test2",
path=temp_file,
)

View file

@ -1,121 +1,72 @@
import json
import pytest
from fastmcp.resources import ResourceTemplate, FunctionResource
from pydantic import BaseModel
from fastmcp.resources import FunctionResource, ResourceTemplate
class TestResourceTemplate:
"""Test ResourceTemplate functionality."""
def test_template_from_function(self):
def test_template_creation(self):
"""Test creating a template from a function."""
def weather(city: str, units: str = "metric") -> str:
return f"Weather in {city} ({units})"
def my_func(key: str, value: int) -> dict:
return {"key": key, "value": value}
template = ResourceTemplate.from_function(
fn=weather,
uri_template="weather://{city}/current",
name="weather",
description="Get current weather",
fn=my_func,
uri_template="test://{key}/{value}",
name="test",
)
assert template.name == "weather"
assert template.uri_template == "weather://{city}/current"
assert template.mime_type == "text/plain"
assert "city" in template.parameters["properties"]
def test_template_from_lambda_error(self):
"""Test error when creating template from lambda without name."""
with pytest.raises(
ValueError, match="You must provide a name for lambda functions"
):
ResourceTemplate.from_function(
fn=lambda x: x,
uri_template="test://{x}",
)
assert template.uri_template == "test://{key}/{value}"
assert template.name == "test"
assert template.mime_type == "text/plain" # default
test_input = {"key": "test", "value": 42}
assert template.fn(**test_input) == my_func(**test_input)
def test_template_matches(self):
"""Test URI matching against template."""
"""Test matching URIs against a template."""
def dummy(x: str) -> str:
return x
def my_func(key: str, value: int) -> dict:
return {"key": key, "value": value}
template = ResourceTemplate.from_function(
fn=dummy,
uri_template="test://{x}/value",
fn=my_func,
uri_template="test://{key}/{value}",
name="test",
)
# Test matching URI
params = template.matches("test://hello/value")
assert params == {"x": "hello"}
# Valid match
params = template.matches("test://foo/123")
assert params == {"key": "foo", "value": "123"}
# Test non-matching URI
params = template.matches("test://hello/wrong")
assert params is None
# No match
assert template.matches("test://foo") is None
assert template.matches("other://foo/123") is None
async def test_create_text_resource(self):
"""Test creating a text resource from template."""
async def test_create_resource(self):
"""Test creating a resource from a template."""
def greet(name: str) -> str:
return f"Hello, {name}!"
def my_func(key: str, value: int) -> dict:
return {"key": key, "value": value}
template = ResourceTemplate.from_function(
fn=greet,
uri_template="greet://{name}",
name="greeter",
fn=my_func,
uri_template="test://{key}/{value}",
name="test",
)
resource = await template.create_resource(
"greet://world",
{"name": "world"},
"test://foo/123",
{"key": "foo", "value": 123},
)
assert isinstance(resource, FunctionResource)
content = await resource.read()
assert content == "Hello, world!"
async def test_create_binary_resource(self):
"""Test creating a binary resource from template."""
def get_bytes(value: str) -> bytes:
return value.encode()
template = ResourceTemplate.from_function(
fn=get_bytes,
uri_template="bytes://{value}",
name="bytes",
)
resource = await template.create_resource(
"bytes://test",
{"value": "test"},
)
assert isinstance(resource, FunctionResource)
content = await resource.read()
assert content == b"test"
async def test_json_conversion(self):
"""Test automatic JSON conversion of non-string/bytes results."""
def get_data(key: str) -> dict:
return {"key": key, "value": 123}
template = ResourceTemplate.from_function(
fn=get_data,
uri_template="data://{key}",
name="data",
)
resource = await template.create_resource(
"data://test",
{"key": "test"},
)
assert isinstance(resource, FunctionResource)
content = await resource.read()
assert '"key": "test"' in content
assert '"value": 123' in content
assert isinstance(content, str)
data = json.loads(content)
assert data == {"key": "foo", "value": 123}
async def test_template_error(self):
"""Test error handling in template resource creation."""
@ -174,65 +125,57 @@ class TestResourceTemplate:
content = await resource.read()
assert content == b"test"
async def test_async_json_conversion(self):
"""Test automatic JSON conversion of async results."""
async def test_basemodel_conversion(self):
"""Test handling of BaseModel types."""
async def get_data(key: str) -> dict:
return {"key": key, "value": 123}
class MyModel(BaseModel):
key: str
value: int
def get_data(key: str, value: int) -> MyModel:
return MyModel(key=key, value=value)
template = ResourceTemplate.from_function(
fn=get_data,
uri_template="data://{key}",
name="data",
uri_template="test://{key}/{value}",
name="test",
)
resource = await template.create_resource(
"data://test",
{"key": "test"},
"test://foo/123",
{"key": "foo", "value": 123},
)
assert isinstance(resource, FunctionResource)
content = await resource.read()
assert '"key": "test"' in content
assert '"value": 123' in content
assert isinstance(content, str)
data = json.loads(content)
assert data == {"key": "foo", "value": 123}
async def test_async_error(self):
"""Test error handling in async template."""
async def test_custom_type_conversion(self):
"""Test handling of custom types."""
async def failing_func(x: str) -> str:
raise ValueError("Test error")
class CustomData:
def __init__(self, value: str):
self.value = value
def __str__(self) -> str:
return self.value
def get_data(value: str) -> CustomData:
return CustomData(value)
template = ResourceTemplate.from_function(
fn=failing_func,
uri_template="fail://{x}",
name="fail",
)
with pytest.raises(
ValueError, match="Error creating resource from template: Test error"
):
await template.create_resource("fail://test", {"x": "test"})
async def test_sync_returning_coroutine(self):
"""Test sync function that returns a coroutine."""
async def async_helper(name: str) -> str:
return f"Hello, {name}!"
def get_greeting(name: str) -> str:
return async_helper(name) # Returns coroutine
template = ResourceTemplate.from_function(
fn=get_greeting,
uri_template="greet://{name}",
name="greeter",
fn=get_data,
uri_template="test://{value}",
name="test",
)
resource = await template.create_resource(
"greet://world",
{"name": "world"},
"test://hello",
{"value": "hello"},
)
assert isinstance(resource, FunctionResource)
content = await resource.read()
assert content == "Hello, world!"
assert content == "hello"

View file

@ -1,4 +1,5 @@
import pytest
from pydantic import AnyUrl
from fastmcp.resources import FunctionResource, Resource
@ -14,7 +15,7 @@ class TestResourceValidation:
# Valid URI
resource = FunctionResource(
uri="http://example.com/data",
uri=AnyUrl("http://example.com/data"),
name="test",
fn=dummy_func,
)
@ -23,7 +24,7 @@ class TestResourceValidation:
# Missing protocol
with pytest.raises(ValueError, match="Input should be a valid URL"):
FunctionResource(
uri="invalid",
uri=AnyUrl("invalid"),
name="test",
fn=dummy_func,
)
@ -31,7 +32,7 @@ class TestResourceValidation:
# Missing host
with pytest.raises(ValueError, match="Input should be a valid URL"):
FunctionResource(
uri="http://",
uri=AnyUrl("http://"),
name="test",
fn=dummy_func,
)
@ -43,7 +44,7 @@ class TestResourceValidation:
return "data"
resource = FunctionResource(
uri="resource://my-resource",
uri=AnyUrl("resource://my-resource"),
fn=dummy_func,
)
assert resource.name == "resource://my-resource"
@ -62,7 +63,7 @@ class TestResourceValidation:
# Explicit name takes precedence over URI
resource = FunctionResource(
uri="resource://uri-name",
uri=AnyUrl("resource://uri-name"),
name="explicit-name",
fn=dummy_func,
)
@ -76,14 +77,14 @@ class TestResourceValidation:
# Default mime type
resource = FunctionResource(
uri="resource://test",
uri=AnyUrl("resource://test"),
fn=dummy_func,
)
assert resource.mime_type == "text/plain"
# Custom mime type
resource = FunctionResource(
uri="resource://test",
uri=AnyUrl("resource://test"),
fn=dummy_func,
mime_type="application/json",
)
@ -96,4 +97,4 @@ class TestResourceValidation:
pass
with pytest.raises(TypeError, match="abstract method"):
ConcreteResource(uri="test://test", name="test") # type: ignore
ConcreteResource(uri=AnyUrl("test://test"), name="test") # type: ignore

View file

@ -25,7 +25,7 @@ def mcp() -> FastMCP:
@pytest.fixture(autouse=True)
def resources(mcp: FastMCP, test_dir: Path) -> None:
def resources(mcp: FastMCP, test_dir: Path) -> FastMCP:
@mcp.resource("dir://test_dir")
def list_test_dir() -> list[str]:
"""List the files in the test directory"""
@ -59,7 +59,7 @@ def resources(mcp: FastMCP, test_dir: Path) -> None:
@pytest.fixture(autouse=True)
def tools(mcp: FastMCP, test_dir: Path) -> None:
def tools(mcp: FastMCP, test_dir: Path) -> FastMCP:
@mcp.tool()
def delete_file(path: str) -> bool:
# ensure path is in test_dir
@ -68,6 +68,8 @@ def tools(mcp: FastMCP, test_dir: Path) -> None:
Path(path).unlink()
return True
return mcp
async def test_list_resources(mcp: FastMCP):
resources = await mcp.list_resources()

View file

@ -320,7 +320,11 @@ mcp = FastMCP("test", dependencies=["pandas", "numpy"])
x in deps_section for x in ["--with", "numpy", "--with", "pandas"]
)
assert mock_run.call_args_list[1][1] == {"check": True, "shell": True}
# Verify subprocess call kwargs, allowing for environment variables
call_kwargs = mock_run.call_args_list[1][1]
assert call_kwargs["check"] is True
assert call_kwargs["shell"] is True
assert isinstance(call_kwargs["env"], dict)
else:
# same verification for unix, just with different command prefix
actual_cmd = mock_run.call_args_list[0][0][0]
@ -342,7 +346,11 @@ mcp = FastMCP("test", dependencies=["pandas", "numpy"])
x in deps_section for x in ["--with", "numpy", "--with", "pandas"]
)
assert mock_run.call_args_list[0][1] == {"check": True, "shell": False}
# Verify subprocess call kwargs, allowing for environment variables
call_kwargs = mock_run.call_args_list[0][1]
assert call_kwargs["check"] is True
assert call_kwargs["shell"] is False
assert isinstance(call_kwargs["env"], dict)
def test_run_with_dependencies(mock_config, server_file):

View file

@ -192,9 +192,9 @@ def test_skip_names():
assert "also_skip" not in meta.arg_model.model_fields
# Validate that we can call with only non-skipped parameters
model = meta.arg_model.model_validate({"keep_this": 1, "also_keep": 2.5})
assert model.keep_this == 1
assert model.also_keep == 2.5
model: BaseModel = meta.arg_model.model_validate({"keep_this": 1, "also_keep": 2.5}) # type: ignore
assert model.keep_this == 1 # type: ignore
assert model.also_keep == 2.5 # type: ignore
async def test_lambda_function():

View file

@ -7,7 +7,13 @@ from mcp.shared.exceptions import McpError
from mcp.shared.memory import (
create_connected_server_and_client_session as client_session,
)
from mcp.types import ImageContent, TextContent
from mcp.types import (
ImageContent,
TextContent,
TextResourceContents,
BlobResourceContents,
)
from pydantic import AnyUrl
from fastmcp import Context, FastMCP
from fastmcp.prompts.base import EmbeddedResource, Message, UserMessage
@ -100,7 +106,7 @@ class TestServerTools:
mcp.add_tool(tool_fn)
async with client_session(mcp._mcp_server) as client:
result = await client.call_tool("my_tool", {"arg1": "value"})
assert "error" not in result
assert not hasattr(result, "error")
assert len(result.content) > 0
async def test_tool_exception_handling(self):
@ -109,29 +115,43 @@ class TestServerTools:
async with client_session(mcp._mcp_server) as client:
result = await client.call_tool("error_tool_fn", {})
assert len(result.content) == 1
assert result.content[0].type == "text"
assert "Test error" in result.content[0].text
content = result.content[0]
assert isinstance(content, TextContent)
assert "Test error" in content.text
assert result.isError is True
async def test_tool_exception_content(self):
async def test_tool_error_handling(self):
mcp = FastMCP()
mcp.add_tool(error_tool_fn)
async with client_session(mcp._mcp_server) as client:
result = await client.call_tool("error_tool_fn", {})
assert len(result.content) == 1
content = result.content[0]
assert isinstance(content, TextContent)
assert "Test error" in content.text
assert result.isError is True
async def test_tool_error_details(self):
"""Test that exception details are properly formatted in the response"""
mcp = FastMCP()
mcp.add_tool(error_tool_fn)
async with client_session(mcp._mcp_server) as client:
result = await client.call_tool("error_tool_fn", {})
assert result.content[0].type == "text"
assert isinstance(result.content[0].text, str)
assert "Test error" in result.content[0].text
content = result.content[0]
assert isinstance(content, TextContent)
assert isinstance(content.text, str)
assert "Test error" in content.text
assert result.isError is True
async def test_tool_text_conversion(self):
async def test_tool_return_value_conversion(self):
mcp = FastMCP()
mcp.add_tool(tool_fn)
async with client_session(mcp._mcp_server) as client:
result = await client.call_tool("tool_fn", {"x": 1, "y": 2})
assert len(result.content) == 1
assert result.content[0].type == "text"
assert result.content[0].text == "3"
content = result.content[0]
assert isinstance(content, TextContent)
assert content.text == "3"
async def test_tool_image_helper(self, tmp_path: Path):
# Create a test image
@ -143,10 +163,12 @@ class TestServerTools:
async with client_session(mcp._mcp_server) as client:
result = await client.call_tool("image_tool_fn", {"path": str(image_path)})
assert len(result.content) == 1
assert result.content[0].type == "image"
assert result.content[0].mimeType == "image/png"
content = result.content[0]
assert isinstance(content, ImageContent)
assert content.type == "image"
assert content.mimeType == "image/png"
# Verify base64 encoding
decoded = base64.b64decode(result.content[0].data)
decoded = base64.b64decode(content.data)
assert decoded == b"fake png data"
async def test_tool_mixed_content(self):
@ -155,11 +177,13 @@ class TestServerTools:
async with client_session(mcp._mcp_server) as client:
result = await client.call_tool("mixed_content_tool_fn", {})
assert len(result.content) == 2
assert result.content[0].type == "text"
assert result.content[0].text == "Hello"
assert result.content[1].type == "image"
assert result.content[1].mimeType == "image/png"
assert result.content[1].data == "abc"
content1 = result.content[0]
content2 = result.content[1]
assert isinstance(content1, TextContent)
assert content1.text == "Hello"
assert isinstance(content2, ImageContent)
assert content2.mimeType == "image/png"
assert content2.data == "abc"
async def test_tool_mixed_list_with_image(self, tmp_path: Path):
"""Test that lists containing Image objects and other types are handled correctly"""
@ -181,18 +205,22 @@ class TestServerTools:
result = await client.call_tool("mixed_list_fn", {})
assert len(result.content) == 4
# Check text conversion
assert result.content[0].type == "text"
assert "text message" in result.content[0].text
content1 = result.content[0]
assert isinstance(content1, TextContent)
assert content1.text == "text message"
# Check image conversion
assert result.content[1].type == "image"
assert result.content[1].mimeType == "image/png"
assert base64.b64decode(result.content[1].data) == b"test image data"
content2 = result.content[1]
assert isinstance(content2, ImageContent)
assert content2.mimeType == "image/png"
assert base64.b64decode(content2.data) == b"test image data"
# Check dict conversion
assert result.content[2].type == "text"
assert '"key": "value"' in result.content[2].text
content3 = result.content[2]
assert isinstance(content3, TextContent)
assert '"key": "value"' in content3.text
# Check direct TextContent
assert result.content[3].type == "text"
assert result.content[3].text == "direct content"
content4 = result.content[3]
assert isinstance(content4, TextContent)
assert content4.text == "direct content"
class TestServerResources:
@ -202,11 +230,14 @@ class TestServerResources:
def get_text():
return "Hello, world!"
resource = FunctionResource(uri="resource://test", name="test", fn=get_text)
resource = FunctionResource(
uri=AnyUrl("resource://test"), name="test", fn=get_text
)
mcp.add_resource(resource)
async with client_session(mcp._mcp_server) as client:
result = await client.read_resource("resource://test")
result = await client.read_resource(AnyUrl("resource://test"))
assert isinstance(result.contents[0], TextResourceContents)
assert result.contents[0].text == "Hello, world!"
async def test_binary_resource(self):
@ -216,16 +247,16 @@ class TestServerResources:
return b"Binary data"
resource = FunctionResource(
uri="resource://binary",
uri=AnyUrl("resource://binary"),
name="binary",
fn=get_binary,
is_binary=True,
mime_type="application/octet-stream",
)
mcp.add_resource(resource)
async with client_session(mcp._mcp_server) as client:
result = await client.read_resource("resource://binary")
result = await client.read_resource(AnyUrl("resource://binary"))
assert isinstance(result.contents[0], BlobResourceContents)
assert result.contents[0].blob == base64.b64encode(b"Binary data").decode()
async def test_file_resource_text(self, tmp_path: Path):
@ -235,11 +266,14 @@ class TestServerResources:
text_file = tmp_path / "test.txt"
text_file.write_text("Hello from file!")
resource = FileResource(uri="file://test.txt", name="test.txt", path=text_file)
resource = FileResource(
uri=AnyUrl("file://test.txt"), name="test.txt", path=text_file
)
mcp.add_resource(resource)
async with client_session(mcp._mcp_server) as client:
result = await client.read_resource("file://test.txt")
result = await client.read_resource(AnyUrl("file://test.txt"))
assert isinstance(result.contents[0], TextResourceContents)
assert result.contents[0].text == "Hello from file!"
async def test_file_resource_binary(self, tmp_path: Path):
@ -250,16 +284,16 @@ class TestServerResources:
binary_file.write_bytes(b"Binary file data")
resource = FileResource(
uri="file://test.bin",
uri=AnyUrl("file://test.bin"),
name="test.bin",
path=binary_file,
is_binary=True,
mime_type="application/octet-stream",
)
mcp.add_resource(resource)
async with client_session(mcp._mcp_server) as client:
result = await client.read_resource("file://test.bin")
result = await client.read_resource(AnyUrl("file://test.bin"))
assert isinstance(result.contents[0], BlobResourceContents)
assert (
result.contents[0].blob
== base64.b64encode(b"Binary file data").decode()
@ -275,7 +309,7 @@ class TestServerResourceTemplates:
with pytest.raises(ValueError, match="Mismatch between URI parameters"):
@mcp.resource("resource://data")
def get_data(param: str) -> str:
def get_data_fn(param: str) -> str:
return f"Data: {param}"
async def test_resource_with_uri_params(self):
@ -305,7 +339,8 @@ class TestServerResourceTemplates:
return f"Data for {name}"
async with client_session(mcp._mcp_server) as client:
result = await client.read_resource("resource://test/data")
result = await client.read_resource(AnyUrl("resource://test/data"))
assert isinstance(result.contents[0], TextResourceContents)
assert result.contents[0].text == "Data for test"
async def test_resource_mismatched_params(self):
@ -327,7 +362,10 @@ class TestServerResourceTemplates:
return f"Data for {org}/{repo}"
async with client_session(mcp._mcp_server) as client:
result = await client.read_resource("resource://cursor/fastmcp/data")
result = await client.read_resource(
AnyUrl("resource://cursor/fastmcp/data")
)
assert isinstance(result.contents[0], TextResourceContents)
assert result.contents[0].text == "Data for cursor/fastmcp"
async def test_resource_multiple_mismatched_params(self):
@ -337,18 +375,19 @@ class TestServerResourceTemplates:
with pytest.raises(ValueError, match="Mismatch between URI parameters"):
@mcp.resource("resource://{org}/{repo}/data")
def get_data(org: str, repo_2: str) -> str:
def get_data_mismatched(org: str, repo_2: str) -> str:
return f"Data for {org}"
"""Test that a resource with no parameters works as a regular resource"""
mcp = FastMCP()
@mcp.resource("resource://static")
def get_data() -> str:
def get_static_data() -> str:
return "Static data"
async with client_session(mcp._mcp_server) as client:
result = await client.read_resource("resource://static")
result = await client.read_resource(AnyUrl("resource://static"))
assert isinstance(result.contents[0], TextResourceContents)
assert result.contents[0].text == "Static data"
async def test_template_to_resource_conversion(self):
@ -395,8 +434,10 @@ class TestContextInjection:
async with client_session(mcp._mcp_server) as client:
result = await client.call_tool("tool_with_context", {"x": 42})
assert len(result.content) == 1
assert "Request" in result.content[0].text
assert "42" in result.content[0].text
content = result.content[0]
assert isinstance(content, TextContent)
assert "Request" in content.text
assert "42" in content.text
async def test_async_context(self):
"""Test that context works in async functions."""
@ -410,8 +451,10 @@ class TestContextInjection:
async with client_session(mcp._mcp_server) as client:
result = await client.call_tool("async_tool", {"x": 42})
assert len(result.content) == 1
assert "Async request" in result.content[0].text
assert "42" in result.content[0].text
content = result.content[0]
assert isinstance(content, TextContent)
assert "Async request" in content.text
assert "42" in content.text
async def test_context_logging(self):
"""Test that context logging methods work."""
@ -428,7 +471,9 @@ class TestContextInjection:
async with client_session(mcp._mcp_server) as client:
result = await client.call_tool("logging_tool", {"msg": "test"})
assert len(result.content) == 1
assert "Logged messages for test" in result.content[0].text
content = result.content[0]
assert isinstance(content, TextContent)
assert "Logged messages for test" in content.text
async def test_optional_context(self):
"""Test that context is optional."""
@ -441,7 +486,9 @@ class TestContextInjection:
async with client_session(mcp._mcp_server) as client:
result = await client.call_tool("no_context", {"x": 21})
assert len(result.content) == 1
assert result.content[0].text == "42"
content = result.content[0]
assert isinstance(content, TextContent)
assert content.text == "42"
async def test_context_resource_access(self):
"""Test that context can access resources."""
@ -459,7 +506,9 @@ class TestContextInjection:
async with client_session(mcp._mcp_server) as client:
result = await client.call_tool("tool_with_resource", {})
assert len(result.content) == 1
assert "Read resource: resource data" in result.content[0].text
content = result.content[0]
assert isinstance(content, TextContent)
assert "Read resource: resource data" in content.text
class TestServerPrompts:
@ -477,23 +526,26 @@ class TestServerPrompts:
assert len(prompts) == 1
assert prompts[0].name == "fn"
# Don't compare functions directly since validate_call wraps them
assert await prompts[0].render() == [
UserMessage(content=TextContent(type="text", text="Hello, world!"))
]
content = await prompts[0].render()
assert isinstance(content[0].content, TextContent)
assert content[0].content.text == "Hello, world!"
def test_prompt_decorator_with_name(self):
async def test_prompt_decorator_with_name(self):
"""Test prompt decorator with custom name."""
mcp = FastMCP()
@mcp.prompt(name="custom")
@mcp.prompt(name="custom_name")
def fn() -> str:
return "Hello, world!"
prompts = mcp._prompt_manager.list_prompts()
assert len(prompts) == 1
assert prompts[0].name == "custom"
assert prompts[0].name == "custom_name"
content = await prompts[0].render()
assert isinstance(content[0].content, TextContent)
assert content[0].content.text == "Hello, world!"
def test_prompt_decorator_with_description(self):
async def test_prompt_decorator_with_description(self):
"""Test prompt decorator with custom description."""
mcp = FastMCP()
@ -504,13 +556,16 @@ class TestServerPrompts:
prompts = mcp._prompt_manager.list_prompts()
assert len(prompts) == 1
assert prompts[0].description == "A custom description"
content = await prompts[0].render()
assert isinstance(content[0].content, TextContent)
assert content[0].content.text == "Hello, world!"
def test_prompt_decorator_error(self):
"""Test error when decorator is used incorrectly."""
mcp = FastMCP()
with pytest.raises(TypeError, match="decorator was used incorrectly"):
@mcp.prompt
@mcp.prompt # type: ignore
def fn() -> str:
return "Hello, world!"
@ -524,13 +579,16 @@ class TestServerPrompts:
async with client_session(mcp._mcp_server) as client:
result = await client.list_prompts()
assert result.prompts is not None
assert len(result.prompts) == 1
assert result.prompts[0].name == "fn"
assert len(result.prompts[0].arguments) == 2
assert result.prompts[0].arguments[0].name == "name"
assert result.prompts[0].arguments[0].required is True
assert result.prompts[0].arguments[1].name == "optional"
assert result.prompts[0].arguments[1].required is False
prompt = result.prompts[0]
assert prompt.name == "fn"
assert prompt.arguments is not None
assert len(prompt.arguments) == 2
assert prompt.arguments[0].name == "name"
assert prompt.arguments[0].required is True
assert prompt.arguments[1].name == "optional"
assert prompt.arguments[1].required is False
async def test_get_prompt(self):
"""Test getting a prompt through MCP protocol."""
@ -543,9 +601,11 @@ class TestServerPrompts:
async with client_session(mcp._mcp_server) as client:
result = await client.get_prompt("fn", {"name": "World"})
assert len(result.messages) == 1
assert result.messages[0].role == "user"
assert result.messages[0].content.type == "text"
assert result.messages[0].content.text == "Hello, World!"
message = result.messages[0]
assert message.role == "user"
content = message.content
assert isinstance(content, TextContent)
assert content.text == "Hello, World!"
async def test_get_prompt_with_resource(self):
"""Test getting a prompt that returns resource content."""
@ -556,22 +616,25 @@ class TestServerPrompts:
return UserMessage(
content=EmbeddedResource(
type="resource",
resource={
"uri": "file://test.txt",
"text": "File contents",
"mimeType": "text/plain",
},
resource=TextResourceContents(
uri=AnyUrl("file://file.txt"),
text="File contents",
mimeType="text/plain",
),
)
)
async with client_session(mcp._mcp_server) as client:
result = await client.get_prompt("fn")
assert len(result.messages) == 1
assert result.messages[0].role == "user"
assert result.messages[0].content.type == "resource"
assert str(result.messages[0].content.resource.uri) == "file://test.txt/"
assert result.messages[0].content.resource.text == "File contents"
assert result.messages[0].content.resource.mimeType == "text/plain"
message = result.messages[0]
assert message.role == "user"
content = message.content
assert isinstance(content, EmbeddedResource)
resource = content.resource
assert isinstance(resource, TextResourceContents)
assert resource.text == "File contents"
assert resource.mimeType == "text/plain"
async def test_get_unknown_prompt(self):
"""Test error when getting unknown prompt."""
@ -585,9 +648,9 @@ class TestServerPrompts:
mcp = FastMCP()
@mcp.prompt()
def fn(name: str) -> str:
def prompt_fn(name: str) -> str:
return f"Hello, {name}!"
async with client_session(mcp._mcp_server) as client:
with pytest.raises(McpError, match="Missing required arguments"):
await client.get_prompt("fn")
await client.get_prompt("prompt_fn")

19
uv.lock generated
View file

@ -228,7 +228,7 @@ wheels = [
[[package]]
name = "fastmcp"
version = "0.3.6.dev0+gf03184b.d20241203"
version = "0.3.6.dev5+g6a13ab9.d20241203"
source = { editable = "." }
dependencies = [
{ name = "httpx" },
@ -245,6 +245,7 @@ dev = [
{ name = "ipython" },
{ name = "pdbpp" },
{ name = "pre-commit" },
{ name = "pyright" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-flakefinder" },
@ -253,6 +254,7 @@ dev = [
]
tests = [
{ name = "pre-commit" },
{ name = "pyright" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-flakefinder" },
@ -271,6 +273,8 @@ requires-dist = [
{ name = "pre-commit", marker = "extra == 'tests'" },
{ name = "pydantic", specifier = ">=2.5.3,<3.0.0" },
{ name = "pydantic-settings", specifier = ">=2.6.1" },
{ name = "pyright", marker = "extra == 'dev'", specifier = ">=1.1.389" },
{ name = "pyright", marker = "extra == 'tests'", specifier = ">=1.1.389" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.3" },
{ name = "pytest", marker = "extra == 'tests'", specifier = ">=8.3.3" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.5" },
@ -730,6 +734,19 @@ version = "0.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/1b/ea40363be0056080454cdbabe880773c3c5bd66d7b13f0c8b8b8c8da1e0c/pyrepl-0.9.0.tar.gz", hash = "sha256:292570f34b5502e871bbb966d639474f2b57fbfcd3373c2d6a2f3d56e681a775", size = 48744 }
[[package]]
name = "pyright"
version = "1.1.389"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nodeenv" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/72/4e/9a5ab8745e7606b88c2c7ca223449ac9d82a71fd5e31df47b453f2cb39a1/pyright-1.1.389.tar.gz", hash = "sha256:716bf8cc174ab8b4dcf6828c3298cac05c5ed775dda9910106a5dcfe4c7fe220", size = 21940 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1b/26/c288cabf8cfc5a27e1aa9e5029b7682c0f920b8074f45d22bf844314d66a/pyright-1.1.389-py3-none-any.whl", hash = "sha256:41e9620bba9254406dc1f621a88ceab5a88af4c826feb4f614d95691ed243a60", size = 18581 },
]
[[package]]
name = "pytest"
version = "8.3.3"