mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-28 02:10:38 +02:00
Add FileSystemProvider for filesystem-based component discovery (#2823)
This commit is contained in:
parent
43811cc16b
commit
87f2edd9c0
14 changed files with 2419 additions and 4 deletions
277
tests/fs/test_decorators.py
Normal file
277
tests/fs/test_decorators.py
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
"""Tests for fastmcp.fs decorators."""
|
||||
|
||||
import pytest
|
||||
|
||||
from fastmcp.fs.decorators import (
|
||||
PromptMeta,
|
||||
ResourceMeta,
|
||||
ToolMeta,
|
||||
get_fs_meta,
|
||||
has_fs_meta,
|
||||
prompt,
|
||||
resource,
|
||||
tool,
|
||||
)
|
||||
|
||||
|
||||
class TestToolDecorator:
|
||||
"""Tests for the @tool decorator."""
|
||||
|
||||
def test_tool_without_parens(self):
|
||||
"""@tool without parentheses should work."""
|
||||
|
||||
@tool
|
||||
def greet(name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
|
||||
assert has_fs_meta(greet)
|
||||
meta = get_fs_meta(greet)
|
||||
assert isinstance(meta, ToolMeta)
|
||||
assert meta.type == "tool"
|
||||
assert meta.name is None # Will use function name
|
||||
|
||||
def test_tool_with_empty_parens(self):
|
||||
"""@tool() with empty parentheses should work."""
|
||||
|
||||
@tool()
|
||||
def greet(name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
|
||||
assert has_fs_meta(greet)
|
||||
meta = get_fs_meta(greet)
|
||||
assert isinstance(meta, ToolMeta)
|
||||
|
||||
def test_tool_with_name_arg(self):
|
||||
"""@tool("name") with name as first arg should work."""
|
||||
|
||||
@tool("custom-greet")
|
||||
def greet(name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
|
||||
meta = get_fs_meta(greet)
|
||||
assert meta is not None
|
||||
assert meta.name == "custom-greet"
|
||||
|
||||
def test_tool_with_name_kwarg(self):
|
||||
"""@tool(name="name") with keyword arg should work."""
|
||||
|
||||
@tool(name="custom-greet")
|
||||
def greet(name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
|
||||
meta = get_fs_meta(greet)
|
||||
assert meta is not None
|
||||
assert meta.name == "custom-greet"
|
||||
|
||||
def test_tool_with_all_metadata(self):
|
||||
"""@tool with all metadata should store it all."""
|
||||
|
||||
@tool(
|
||||
name="custom-greet",
|
||||
title="Greeting Tool",
|
||||
description="Greets people",
|
||||
tags={"greeting", "demo"},
|
||||
meta={"custom": "value"},
|
||||
)
|
||||
def greet(name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
|
||||
meta = get_fs_meta(greet)
|
||||
assert meta is not None
|
||||
assert meta.name == "custom-greet"
|
||||
assert meta.title == "Greeting Tool"
|
||||
assert meta.description == "Greets people"
|
||||
assert meta.tags == {"greeting", "demo"}
|
||||
assert meta.meta == {"custom": "value"}
|
||||
|
||||
def test_tool_preserves_function(self):
|
||||
"""@tool should preserve the original function."""
|
||||
|
||||
@tool
|
||||
def greet(name: str) -> str:
|
||||
"""Greet someone."""
|
||||
return f"Hello, {name}!"
|
||||
|
||||
# Function should still work
|
||||
assert greet("World") == "Hello, World!"
|
||||
assert greet.__name__ == "greet"
|
||||
assert greet.__doc__ == "Greet someone."
|
||||
|
||||
|
||||
class TestResourceDecorator:
|
||||
"""Tests for the @resource decorator."""
|
||||
|
||||
def test_resource_requires_uri(self):
|
||||
"""@resource should require a URI argument."""
|
||||
with pytest.raises(TypeError, match="requires a URI"):
|
||||
|
||||
@resource # type: ignore[arg-type]
|
||||
def get_config() -> str:
|
||||
return "{}"
|
||||
|
||||
def test_resource_with_uri(self):
|
||||
"""@resource("uri") should store the URI."""
|
||||
|
||||
@resource("config://app")
|
||||
def get_config() -> dict:
|
||||
return {"setting": "value"}
|
||||
|
||||
assert has_fs_meta(get_config)
|
||||
meta = get_fs_meta(get_config)
|
||||
assert isinstance(meta, ResourceMeta)
|
||||
assert meta.type == "resource"
|
||||
assert meta.uri == "config://app"
|
||||
|
||||
def test_resource_with_template_uri(self):
|
||||
"""@resource with template URI should work."""
|
||||
|
||||
@resource("users://{user_id}/profile")
|
||||
def get_profile(user_id: str) -> dict:
|
||||
return {"id": user_id}
|
||||
|
||||
meta = get_fs_meta(get_profile)
|
||||
assert isinstance(meta, ResourceMeta)
|
||||
assert meta.uri == "users://{user_id}/profile"
|
||||
|
||||
def test_resource_with_all_metadata(self):
|
||||
"""@resource with all metadata should store it all."""
|
||||
|
||||
@resource(
|
||||
"config://app",
|
||||
name="app-config",
|
||||
title="Application Config",
|
||||
description="Gets app configuration",
|
||||
mime_type="application/json",
|
||||
tags={"config"},
|
||||
meta={"custom": "value"},
|
||||
)
|
||||
def get_config() -> dict:
|
||||
return {"setting": "value"}
|
||||
|
||||
meta = get_fs_meta(get_config)
|
||||
assert isinstance(meta, ResourceMeta)
|
||||
assert meta.uri == "config://app"
|
||||
assert meta.name == "app-config"
|
||||
assert meta.title == "Application Config"
|
||||
assert meta.description == "Gets app configuration"
|
||||
assert meta.mime_type == "application/json"
|
||||
assert meta.tags == {"config"}
|
||||
assert meta.meta == {"custom": "value"}
|
||||
|
||||
def test_resource_preserves_function(self):
|
||||
"""@resource should preserve the original function."""
|
||||
|
||||
@resource("config://app")
|
||||
def get_config() -> dict:
|
||||
"""Get config."""
|
||||
return {"setting": "value"}
|
||||
|
||||
# Function should still work
|
||||
assert get_config() == {"setting": "value"}
|
||||
assert get_config.__name__ == "get_config"
|
||||
assert get_config.__doc__ == "Get config."
|
||||
|
||||
|
||||
class TestPromptDecorator:
|
||||
"""Tests for the @prompt decorator."""
|
||||
|
||||
def test_prompt_without_parens(self):
|
||||
"""@prompt without parentheses should work."""
|
||||
|
||||
@prompt
|
||||
def analyze(topic: str) -> list:
|
||||
return [{"role": "user", "content": f"Analyze: {topic}"}]
|
||||
|
||||
assert has_fs_meta(analyze)
|
||||
meta = get_fs_meta(analyze)
|
||||
assert isinstance(meta, PromptMeta)
|
||||
assert meta.type == "prompt"
|
||||
assert meta.name is None
|
||||
|
||||
def test_prompt_with_empty_parens(self):
|
||||
"""@prompt() with empty parentheses should work."""
|
||||
|
||||
@prompt()
|
||||
def analyze(topic: str) -> list:
|
||||
return [{"role": "user", "content": f"Analyze: {topic}"}]
|
||||
|
||||
assert has_fs_meta(analyze)
|
||||
meta = get_fs_meta(analyze)
|
||||
assert isinstance(meta, PromptMeta)
|
||||
|
||||
def test_prompt_with_name_arg(self):
|
||||
"""@prompt("name") with name as first arg should work."""
|
||||
|
||||
@prompt("custom-analyze")
|
||||
def analyze(topic: str) -> list:
|
||||
return [{"role": "user", "content": f"Analyze: {topic}"}]
|
||||
|
||||
meta = get_fs_meta(analyze)
|
||||
assert meta is not None
|
||||
assert meta.name == "custom-analyze"
|
||||
|
||||
def test_prompt_with_name_kwarg(self):
|
||||
"""@prompt(name="name") with keyword arg should work."""
|
||||
|
||||
@prompt(name="custom-analyze")
|
||||
def analyze(topic: str) -> list:
|
||||
return [{"role": "user", "content": f"Analyze: {topic}"}]
|
||||
|
||||
meta = get_fs_meta(analyze)
|
||||
assert meta is not None
|
||||
assert meta.name == "custom-analyze"
|
||||
|
||||
def test_prompt_with_all_metadata(self):
|
||||
"""@prompt with all metadata should store it all."""
|
||||
|
||||
@prompt(
|
||||
name="custom-analyze",
|
||||
title="Analysis Prompt",
|
||||
description="Analyzes topics",
|
||||
tags={"analysis"},
|
||||
meta={"custom": "value"},
|
||||
)
|
||||
def analyze(topic: str) -> list:
|
||||
return [{"role": "user", "content": f"Analyze: {topic}"}]
|
||||
|
||||
meta = get_fs_meta(analyze)
|
||||
assert meta is not None
|
||||
assert meta.name == "custom-analyze"
|
||||
assert meta.title == "Analysis Prompt"
|
||||
assert meta.description == "Analyzes topics"
|
||||
assert meta.tags == {"analysis"}
|
||||
assert meta.meta == {"custom": "value"}
|
||||
|
||||
def test_prompt_preserves_function(self):
|
||||
"""@prompt should preserve the original function."""
|
||||
|
||||
@prompt
|
||||
def analyze(topic: str) -> list:
|
||||
"""Analyze a topic."""
|
||||
return [{"role": "user", "content": f"Analyze: {topic}"}]
|
||||
|
||||
# Function should still work
|
||||
result = analyze("Python")
|
||||
assert result == [{"role": "user", "content": "Analyze: Python"}]
|
||||
assert analyze.__name__ == "analyze"
|
||||
assert analyze.__doc__ == "Analyze a topic."
|
||||
|
||||
|
||||
class TestHelperFunctions:
|
||||
"""Tests for helper functions."""
|
||||
|
||||
def test_has_fs_meta_false_for_undecorated(self):
|
||||
"""has_fs_meta should return False for undecorated functions."""
|
||||
|
||||
def plain_function():
|
||||
pass
|
||||
|
||||
assert not has_fs_meta(plain_function)
|
||||
|
||||
def test_get_fs_meta_none_for_undecorated(self):
|
||||
"""get_fs_meta should return None for undecorated functions."""
|
||||
|
||||
def plain_function():
|
||||
pass
|
||||
|
||||
assert get_fs_meta(plain_function) is None
|
||||
328
tests/fs/test_discovery.py
Normal file
328
tests/fs/test_discovery.py
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
"""Tests for fastmcp.fs discovery module."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastmcp.fs.decorators import ToolMeta
|
||||
from fastmcp.fs.discovery import (
|
||||
discover_and_import,
|
||||
discover_files,
|
||||
extract_components,
|
||||
import_module_from_file,
|
||||
)
|
||||
|
||||
|
||||
class TestDiscoverFiles:
|
||||
"""Tests for discover_files function."""
|
||||
|
||||
def test_discover_files_empty_dir(self, tmp_path: Path):
|
||||
"""Should return empty list for empty directory."""
|
||||
files = discover_files(tmp_path)
|
||||
assert files == []
|
||||
|
||||
def test_discover_files_nonexistent_dir(self, tmp_path: Path):
|
||||
"""Should return empty list for nonexistent directory."""
|
||||
nonexistent = tmp_path / "does_not_exist"
|
||||
files = discover_files(nonexistent)
|
||||
assert files == []
|
||||
|
||||
def test_discover_files_single_file(self, tmp_path: Path):
|
||||
"""Should find a single Python file."""
|
||||
py_file = tmp_path / "test.py"
|
||||
py_file.write_text("# test")
|
||||
|
||||
files = discover_files(tmp_path)
|
||||
assert files == [py_file]
|
||||
|
||||
def test_discover_files_skips_init(self, tmp_path: Path):
|
||||
"""Should skip __init__.py files."""
|
||||
init_file = tmp_path / "__init__.py"
|
||||
init_file.write_text("# init")
|
||||
py_file = tmp_path / "test.py"
|
||||
py_file.write_text("# test")
|
||||
|
||||
files = discover_files(tmp_path)
|
||||
assert files == [py_file]
|
||||
|
||||
def test_discover_files_recursive(self, tmp_path: Path):
|
||||
"""Should find files in subdirectories."""
|
||||
subdir = tmp_path / "subdir"
|
||||
subdir.mkdir()
|
||||
file1 = tmp_path / "a.py"
|
||||
file2 = subdir / "b.py"
|
||||
file1.write_text("# a")
|
||||
file2.write_text("# b")
|
||||
|
||||
files = discover_files(tmp_path)
|
||||
assert sorted(files) == sorted([file1, file2])
|
||||
|
||||
def test_discover_files_skips_pycache(self, tmp_path: Path):
|
||||
"""Should skip __pycache__ directories."""
|
||||
pycache = tmp_path / "__pycache__"
|
||||
pycache.mkdir()
|
||||
cache_file = pycache / "test.py"
|
||||
cache_file.write_text("# cache")
|
||||
py_file = tmp_path / "test.py"
|
||||
py_file.write_text("# test")
|
||||
|
||||
files = discover_files(tmp_path)
|
||||
assert files == [py_file]
|
||||
|
||||
def test_discover_files_sorted(self, tmp_path: Path):
|
||||
"""Files should be returned in sorted order."""
|
||||
(tmp_path / "z.py").write_text("# z")
|
||||
(tmp_path / "a.py").write_text("# a")
|
||||
(tmp_path / "m.py").write_text("# m")
|
||||
|
||||
files = discover_files(tmp_path)
|
||||
names = [f.name for f in files]
|
||||
assert names == ["a.py", "m.py", "z.py"]
|
||||
|
||||
|
||||
class TestImportModuleFromFile:
|
||||
"""Tests for import_module_from_file function."""
|
||||
|
||||
def test_import_simple_module(self, tmp_path: Path):
|
||||
"""Should import a simple module."""
|
||||
py_file = tmp_path / "simple.py"
|
||||
py_file.write_text("VALUE = 42")
|
||||
|
||||
module = import_module_from_file(py_file)
|
||||
assert module.VALUE == 42
|
||||
|
||||
def test_import_module_with_function(self, tmp_path: Path):
|
||||
"""Should import a module with functions."""
|
||||
py_file = tmp_path / "funcs.py"
|
||||
py_file.write_text(
|
||||
"""\
|
||||
def greet(name):
|
||||
return f"Hello, {name}!"
|
||||
"""
|
||||
)
|
||||
|
||||
module = import_module_from_file(py_file)
|
||||
assert module.greet("World") == "Hello, World!"
|
||||
|
||||
def test_import_module_with_imports(self, tmp_path: Path):
|
||||
"""Should handle modules with standard library imports."""
|
||||
py_file = tmp_path / "with_imports.py"
|
||||
py_file.write_text(
|
||||
"""\
|
||||
import os
|
||||
import sys
|
||||
|
||||
def get_cwd():
|
||||
return os.getcwd()
|
||||
"""
|
||||
)
|
||||
|
||||
module = import_module_from_file(py_file)
|
||||
assert callable(module.get_cwd)
|
||||
|
||||
def test_import_as_package_with_init(self, tmp_path: Path):
|
||||
"""Should import as package when __init__.py exists."""
|
||||
# Create package structure (use unique name to avoid module caching)
|
||||
pkg = tmp_path / "testpkg_init"
|
||||
pkg.mkdir()
|
||||
(pkg / "__init__.py").write_text("PKG_VAR = 'package'")
|
||||
module_file = pkg / "module.py"
|
||||
module_file.write_text("MODULE_VAR = 'module'")
|
||||
|
||||
module = import_module_from_file(module_file)
|
||||
assert module.MODULE_VAR == "module"
|
||||
|
||||
def test_import_with_relative_import(self, tmp_path: Path):
|
||||
"""Should support relative imports when in a package."""
|
||||
# Create package with relative import (use unique name to avoid module caching)
|
||||
pkg = tmp_path / "testpkg_relative"
|
||||
pkg.mkdir()
|
||||
(pkg / "__init__.py").write_text("")
|
||||
(pkg / "helper.py").write_text("HELPER_VALUE = 123")
|
||||
(pkg / "main.py").write_text(
|
||||
"""\
|
||||
from .helper import HELPER_VALUE
|
||||
|
||||
MAIN_VALUE = HELPER_VALUE * 2
|
||||
"""
|
||||
)
|
||||
|
||||
module = import_module_from_file(pkg / "main.py")
|
||||
assert module.MAIN_VALUE == 246
|
||||
|
||||
def test_import_package_module_reload(self, tmp_path: Path):
|
||||
"""Re-importing a package module should return updated content."""
|
||||
# Create package (use unique name to avoid conflicts)
|
||||
pkg = tmp_path / "testpkg_reload"
|
||||
pkg.mkdir()
|
||||
(pkg / "__init__.py").write_text("")
|
||||
module_file = pkg / "reloadable.py"
|
||||
module_file.write_text("VALUE = 'original'")
|
||||
|
||||
# First import
|
||||
module = import_module_from_file(module_file)
|
||||
assert module.VALUE == "original"
|
||||
|
||||
# Modify the file
|
||||
module_file.write_text("VALUE = 'updated'")
|
||||
|
||||
# Re-import should see the updated value
|
||||
module = import_module_from_file(module_file)
|
||||
assert module.VALUE == "updated"
|
||||
|
||||
|
||||
class TestExtractComponents:
|
||||
"""Tests for extract_components function."""
|
||||
|
||||
def test_extract_no_components(self, tmp_path: Path):
|
||||
"""Should return empty list for module with no decorated functions."""
|
||||
py_file = tmp_path / "plain.py"
|
||||
py_file.write_text(
|
||||
"""\
|
||||
def plain_function():
|
||||
pass
|
||||
|
||||
SOME_VAR = 42
|
||||
"""
|
||||
)
|
||||
|
||||
module = import_module_from_file(py_file)
|
||||
components = extract_components(module)
|
||||
assert components == []
|
||||
|
||||
def test_extract_tool_component(self, tmp_path: Path):
|
||||
"""Should extract @tool decorated functions."""
|
||||
py_file = tmp_path / "tools.py"
|
||||
py_file.write_text(
|
||||
"""\
|
||||
from fastmcp.fs import tool
|
||||
|
||||
@tool
|
||||
def greet(name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
"""
|
||||
)
|
||||
|
||||
module = import_module_from_file(py_file)
|
||||
components = extract_components(module)
|
||||
|
||||
assert len(components) == 1
|
||||
func, meta = components[0]
|
||||
assert func.__name__ == "greet"
|
||||
assert isinstance(meta, ToolMeta)
|
||||
|
||||
def test_extract_multiple_components(self, tmp_path: Path):
|
||||
"""Should extract multiple decorated functions."""
|
||||
py_file = tmp_path / "multi.py"
|
||||
py_file.write_text(
|
||||
"""\
|
||||
from fastmcp.fs import tool, resource, prompt
|
||||
|
||||
@tool
|
||||
def greet(name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
|
||||
@resource("config://app")
|
||||
def get_config() -> dict:
|
||||
return {}
|
||||
|
||||
@prompt
|
||||
def analyze(topic: str) -> list:
|
||||
return []
|
||||
"""
|
||||
)
|
||||
|
||||
module = import_module_from_file(py_file)
|
||||
components = extract_components(module)
|
||||
|
||||
assert len(components) == 3
|
||||
names = {func.__name__ for func, _ in components}
|
||||
assert names == {"greet", "get_config", "analyze"}
|
||||
|
||||
def test_extract_skips_private_functions(self, tmp_path: Path):
|
||||
"""Should skip private functions even if decorated."""
|
||||
py_file = tmp_path / "private.py"
|
||||
py_file.write_text(
|
||||
"""\
|
||||
from fastmcp.fs import tool
|
||||
|
||||
@tool
|
||||
def public_tool() -> str:
|
||||
return "public"
|
||||
|
||||
@tool
|
||||
def _private_tool() -> str:
|
||||
return "private"
|
||||
"""
|
||||
)
|
||||
|
||||
module = import_module_from_file(py_file)
|
||||
components = extract_components(module)
|
||||
|
||||
# Only public tool should be found (private starts with _)
|
||||
assert len(components) == 1
|
||||
func, _ = components[0]
|
||||
assert func.__name__ == "public_tool"
|
||||
|
||||
|
||||
class TestDiscoverAndImport:
|
||||
"""Tests for discover_and_import function."""
|
||||
|
||||
def test_discover_and_import_empty(self, tmp_path: Path):
|
||||
"""Should return empty result for empty directory."""
|
||||
result = discover_and_import(tmp_path)
|
||||
assert result.components == []
|
||||
assert result.failed_files == {}
|
||||
|
||||
def test_discover_and_import_with_tools(self, tmp_path: Path):
|
||||
"""Should discover and import tools."""
|
||||
tools_dir = tmp_path / "tools"
|
||||
tools_dir.mkdir()
|
||||
(tools_dir / "greet.py").write_text(
|
||||
"""\
|
||||
from fastmcp.fs import tool
|
||||
|
||||
@tool
|
||||
def greet(name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
"""
|
||||
)
|
||||
|
||||
result = discover_and_import(tmp_path)
|
||||
|
||||
assert len(result.components) == 1
|
||||
file_path, func, meta = result.components[0]
|
||||
assert file_path.name == "greet.py"
|
||||
assert func.__name__ == "greet"
|
||||
assert isinstance(meta, ToolMeta)
|
||||
|
||||
def test_discover_and_import_skips_bad_imports(self, tmp_path: Path):
|
||||
"""Should skip files that fail to import and track them."""
|
||||
(tmp_path / "good.py").write_text(
|
||||
"""\
|
||||
from fastmcp.fs import tool
|
||||
|
||||
@tool
|
||||
def good_tool() -> str:
|
||||
return "good"
|
||||
"""
|
||||
)
|
||||
(tmp_path / "bad.py").write_text(
|
||||
"""\
|
||||
import nonexistent_module_xyz123
|
||||
|
||||
def bad_function():
|
||||
pass
|
||||
"""
|
||||
)
|
||||
|
||||
result = discover_and_import(tmp_path)
|
||||
|
||||
# Only good.py should be imported
|
||||
assert len(result.components) == 1
|
||||
_, func, _ = result.components[0]
|
||||
assert func.__name__ == "good_tool"
|
||||
|
||||
# bad.py should be in failed_files
|
||||
assert len(result.failed_files) == 1
|
||||
failed_path = tmp_path / "bad.py"
|
||||
assert failed_path in result.failed_files
|
||||
assert "nonexistent_module_xyz123" in result.failed_files[failed_path]
|
||||
420
tests/fs/test_provider.py
Normal file
420
tests/fs/test_provider.py
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
"""Tests for fastmcp.fs FileSystemProvider."""
|
||||
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.client import Client
|
||||
from fastmcp.fs import FileSystemProvider
|
||||
|
||||
|
||||
class TestFileSystemProvider:
|
||||
"""Tests for FileSystemProvider."""
|
||||
|
||||
def test_provider_empty_directory(self, tmp_path: Path):
|
||||
"""Provider should work with empty directory."""
|
||||
provider = FileSystemProvider(tmp_path)
|
||||
assert repr(provider).startswith("FileSystemProvider")
|
||||
|
||||
def test_provider_discovers_tools(self, tmp_path: Path):
|
||||
"""Provider should discover @tool decorated functions."""
|
||||
tools_dir = tmp_path / "tools"
|
||||
tools_dir.mkdir()
|
||||
(tools_dir / "greet.py").write_text(
|
||||
"""\
|
||||
from fastmcp.fs import tool
|
||||
|
||||
@tool
|
||||
def greet(name: str) -> str:
|
||||
'''Greet someone by name.'''
|
||||
return f"Hello, {name}!"
|
||||
"""
|
||||
)
|
||||
|
||||
provider = FileSystemProvider(tmp_path)
|
||||
|
||||
# Check tool was registered
|
||||
assert len(provider._components) == 1
|
||||
|
||||
def test_provider_discovers_resources(self, tmp_path: Path):
|
||||
"""Provider should discover @resource decorated functions."""
|
||||
(tmp_path / "config.py").write_text(
|
||||
"""\
|
||||
from fastmcp.fs import resource
|
||||
|
||||
@resource("config://app")
|
||||
def get_config() -> dict:
|
||||
'''Get app config.'''
|
||||
return {"setting": "value"}
|
||||
"""
|
||||
)
|
||||
|
||||
provider = FileSystemProvider(tmp_path)
|
||||
assert len(provider._components) == 1
|
||||
|
||||
def test_provider_discovers_resource_templates(self, tmp_path: Path):
|
||||
"""Provider should discover resource templates."""
|
||||
(tmp_path / "users.py").write_text(
|
||||
"""\
|
||||
from fastmcp.fs import resource
|
||||
|
||||
@resource("users://{user_id}/profile")
|
||||
def get_profile(user_id: str) -> dict:
|
||||
'''Get user profile.'''
|
||||
return {"id": user_id}
|
||||
"""
|
||||
)
|
||||
|
||||
provider = FileSystemProvider(tmp_path)
|
||||
assert len(provider._components) == 1
|
||||
|
||||
def test_provider_discovers_prompts(self, tmp_path: Path):
|
||||
"""Provider should discover @prompt decorated functions."""
|
||||
(tmp_path / "analyze.py").write_text(
|
||||
"""\
|
||||
from fastmcp.fs import prompt
|
||||
|
||||
@prompt
|
||||
def analyze(topic: str) -> list:
|
||||
'''Analyze a topic.'''
|
||||
return [{"role": "user", "content": f"Analyze: {topic}"}]
|
||||
"""
|
||||
)
|
||||
|
||||
provider = FileSystemProvider(tmp_path)
|
||||
assert len(provider._components) == 1
|
||||
|
||||
def test_provider_discovers_multiple_in_one_file(self, tmp_path: Path):
|
||||
"""Provider should discover multiple components in one file."""
|
||||
(tmp_path / "multi.py").write_text(
|
||||
"""\
|
||||
from fastmcp.fs import tool, resource, prompt
|
||||
|
||||
@tool
|
||||
def tool1() -> str:
|
||||
return "tool1"
|
||||
|
||||
@tool
|
||||
def tool2() -> str:
|
||||
return "tool2"
|
||||
|
||||
@resource("config://app")
|
||||
def get_config() -> dict:
|
||||
return {}
|
||||
"""
|
||||
)
|
||||
|
||||
provider = FileSystemProvider(tmp_path)
|
||||
assert len(provider._components) == 3
|
||||
|
||||
def test_provider_skips_undecorated_files(self, tmp_path: Path):
|
||||
"""Provider should skip files with no decorated functions."""
|
||||
(tmp_path / "utils.py").write_text(
|
||||
"""\
|
||||
def helper_function():
|
||||
return "helper"
|
||||
|
||||
SOME_CONSTANT = 42
|
||||
"""
|
||||
)
|
||||
(tmp_path / "tool.py").write_text(
|
||||
"""\
|
||||
from fastmcp.fs import tool
|
||||
|
||||
@tool
|
||||
def my_tool() -> str:
|
||||
return "tool"
|
||||
"""
|
||||
)
|
||||
|
||||
provider = FileSystemProvider(tmp_path)
|
||||
# Only the tool should be registered
|
||||
assert len(provider._components) == 1
|
||||
|
||||
|
||||
class TestFileSystemProviderReloadMode:
|
||||
"""Tests for FileSystemProvider reload mode."""
|
||||
|
||||
def test_reload_false_caches_at_init(self, tmp_path: Path):
|
||||
"""With reload=False, components are cached at init."""
|
||||
(tmp_path / "tool.py").write_text(
|
||||
"""\
|
||||
from fastmcp.fs import tool
|
||||
|
||||
@tool
|
||||
def original() -> str:
|
||||
return "original"
|
||||
"""
|
||||
)
|
||||
|
||||
provider = FileSystemProvider(tmp_path, reload=False)
|
||||
assert len(provider._components) == 1
|
||||
|
||||
# Add another file - should NOT be picked up
|
||||
(tmp_path / "tool2.py").write_text(
|
||||
"""\
|
||||
from fastmcp.fs import tool
|
||||
|
||||
@tool
|
||||
def added() -> str:
|
||||
return "added"
|
||||
"""
|
||||
)
|
||||
|
||||
# Still only one component
|
||||
assert len(provider._components) == 1
|
||||
|
||||
async def test_reload_true_rescans(self, tmp_path: Path):
|
||||
"""With reload=True, components are rescanned on each request."""
|
||||
(tmp_path / "tool.py").write_text(
|
||||
"""\
|
||||
from fastmcp.fs import tool
|
||||
|
||||
@tool
|
||||
def original() -> str:
|
||||
return "original"
|
||||
"""
|
||||
)
|
||||
|
||||
provider = FileSystemProvider(tmp_path, reload=True)
|
||||
|
||||
# Always loaded once at init (to catch errors early)
|
||||
assert provider._loaded
|
||||
assert len(provider._components) == 1
|
||||
|
||||
# Add another file - should be picked up on next _ensure_loaded
|
||||
(tmp_path / "tool2.py").write_text(
|
||||
"""\
|
||||
from fastmcp.fs import tool
|
||||
|
||||
@tool
|
||||
def added() -> str:
|
||||
return "added"
|
||||
"""
|
||||
)
|
||||
|
||||
# With reload=True, _ensure_loaded re-scans
|
||||
await provider._ensure_loaded()
|
||||
assert len(provider._components) == 2
|
||||
|
||||
async def test_warning_deduplication_same_file(self, tmp_path: Path, capsys):
|
||||
"""Warnings for the same broken file should not repeat."""
|
||||
bad_file = tmp_path / "bad.py"
|
||||
bad_file.write_text("1/0 # division by zero")
|
||||
|
||||
provider = FileSystemProvider(tmp_path, reload=True)
|
||||
|
||||
# First load - should warn
|
||||
captured = capsys.readouterr()
|
||||
# Check for warning indicator (rich may truncate long paths)
|
||||
assert "WARNING" in captured.err and "Failed to import" in captured.err
|
||||
|
||||
# Second load (same file, unchanged) - should NOT warn again
|
||||
await provider._ensure_loaded()
|
||||
captured = capsys.readouterr()
|
||||
assert "Failed to import" not in captured.err
|
||||
|
||||
async def test_warning_on_file_change(self, tmp_path: Path, capsys):
|
||||
"""Warnings should reappear when a broken file changes."""
|
||||
bad_file = tmp_path / "bad.py"
|
||||
bad_file.write_text("1/0 # division by zero")
|
||||
|
||||
provider = FileSystemProvider(tmp_path, reload=True)
|
||||
|
||||
# First load - should warn
|
||||
captured = capsys.readouterr()
|
||||
# Check for warning indicator (rich may truncate long paths)
|
||||
assert "WARNING" in captured.err and "Failed to import" in captured.err
|
||||
|
||||
# Modify the file (different error) - need to ensure mtime changes
|
||||
time.sleep(0.01) # Ensure mtime differs
|
||||
bad_file.write_text("syntax error here !!!")
|
||||
|
||||
# Next load - should warn again (file changed)
|
||||
await provider._ensure_loaded()
|
||||
captured = capsys.readouterr()
|
||||
# Check for warning indicator (rich may truncate long paths)
|
||||
assert "WARNING" in captured.err and "Failed to import" in captured.err
|
||||
|
||||
async def test_warning_cleared_when_fixed(self, tmp_path: Path, capsys):
|
||||
"""Warnings should clear when a file is fixed, and reappear if broken again."""
|
||||
bad_file = tmp_path / "tool.py"
|
||||
bad_file.write_text("1/0 # broken")
|
||||
|
||||
provider = FileSystemProvider(tmp_path, reload=True)
|
||||
|
||||
# First load - should warn
|
||||
captured = capsys.readouterr()
|
||||
# Check for warning indicator (rich may truncate long paths)
|
||||
assert "WARNING" in captured.err and "Failed to import" in captured.err
|
||||
|
||||
# Fix the file
|
||||
time.sleep(0.01)
|
||||
bad_file.write_text(
|
||||
"""\
|
||||
from fastmcp.fs import tool
|
||||
|
||||
@tool
|
||||
def my_tool() -> str:
|
||||
return "fixed"
|
||||
"""
|
||||
)
|
||||
|
||||
# Load again - should NOT warn, file is fixed
|
||||
await provider._ensure_loaded()
|
||||
captured = capsys.readouterr()
|
||||
assert "Failed to import" not in captured.err
|
||||
assert len(provider._components) == 1
|
||||
|
||||
# Break it again
|
||||
time.sleep(0.01)
|
||||
bad_file.write_text("1/0 # broken again")
|
||||
|
||||
# Should warn again
|
||||
await provider._ensure_loaded()
|
||||
captured = capsys.readouterr()
|
||||
# Check for warning indicator (rich may truncate long paths)
|
||||
assert "WARNING" in captured.err and "Failed to import" in captured.err
|
||||
|
||||
|
||||
class TestFileSystemProviderIntegration:
|
||||
"""Integration tests with FastMCP server."""
|
||||
|
||||
async def test_provider_with_fastmcp_server(self, tmp_path: Path):
|
||||
"""FileSystemProvider should work with FastMCP server."""
|
||||
(tmp_path / "greet.py").write_text(
|
||||
"""\
|
||||
from fastmcp.fs import tool
|
||||
|
||||
@tool
|
||||
def greet(name: str) -> str:
|
||||
'''Greet someone.'''
|
||||
return f"Hello, {name}!"
|
||||
"""
|
||||
)
|
||||
|
||||
provider = FileSystemProvider(tmp_path)
|
||||
mcp = FastMCP("TestServer", providers=[provider])
|
||||
|
||||
async with Client(mcp) as client:
|
||||
# List tools
|
||||
tools = await client.list_tools()
|
||||
assert len(tools) == 1
|
||||
assert tools[0].name == "greet"
|
||||
|
||||
# Call tool
|
||||
result = await client.call_tool("greet", {"name": "World"})
|
||||
assert "Hello, World!" in str(result)
|
||||
|
||||
async def test_provider_with_resources(self, tmp_path: Path):
|
||||
"""FileSystemProvider should work with resources."""
|
||||
(tmp_path / "config.py").write_text(
|
||||
"""\
|
||||
from fastmcp.fs import resource
|
||||
|
||||
@resource("config://app")
|
||||
def get_config() -> str:
|
||||
'''Get app config.'''
|
||||
return '{"version": "1.0"}'
|
||||
"""
|
||||
)
|
||||
|
||||
provider = FileSystemProvider(tmp_path)
|
||||
mcp = FastMCP("TestServer", providers=[provider])
|
||||
|
||||
async with Client(mcp) as client:
|
||||
# List resources
|
||||
resources = await client.list_resources()
|
||||
assert len(resources) == 1
|
||||
assert str(resources[0].uri) == "config://app"
|
||||
|
||||
# Read resource
|
||||
result = await client.read_resource("config://app")
|
||||
assert "1.0" in str(result)
|
||||
|
||||
async def test_provider_with_resource_templates(self, tmp_path: Path):
|
||||
"""FileSystemProvider should work with resource templates."""
|
||||
(tmp_path / "users.py").write_text(
|
||||
"""\
|
||||
from fastmcp.fs import resource
|
||||
|
||||
@resource("users://{user_id}/profile")
|
||||
def get_profile(user_id: str) -> str:
|
||||
'''Get user profile.'''
|
||||
return f'{{"id": "{user_id}", "name": "User {user_id}"}}'
|
||||
"""
|
||||
)
|
||||
|
||||
provider = FileSystemProvider(tmp_path)
|
||||
mcp = FastMCP("TestServer", providers=[provider])
|
||||
|
||||
async with Client(mcp) as client:
|
||||
# List templates
|
||||
templates = await client.list_resource_templates()
|
||||
assert len(templates) == 1
|
||||
|
||||
# Read with parameter
|
||||
result = await client.read_resource("users://123/profile")
|
||||
assert "123" in str(result)
|
||||
|
||||
async def test_provider_with_prompts(self, tmp_path: Path):
|
||||
"""FileSystemProvider should work with prompts."""
|
||||
(tmp_path / "analyze.py").write_text(
|
||||
"""\
|
||||
from fastmcp.fs import prompt
|
||||
|
||||
@prompt
|
||||
def analyze(topic: str) -> str:
|
||||
'''Analyze a topic.'''
|
||||
return f"Please analyze: {topic}"
|
||||
"""
|
||||
)
|
||||
|
||||
provider = FileSystemProvider(tmp_path)
|
||||
mcp = FastMCP("TestServer", providers=[provider])
|
||||
|
||||
async with Client(mcp) as client:
|
||||
# List prompts
|
||||
prompts = await client.list_prompts()
|
||||
assert len(prompts) == 1
|
||||
assert prompts[0].name == "analyze"
|
||||
|
||||
# Get prompt
|
||||
result = await client.get_prompt("analyze", {"topic": "Python"})
|
||||
assert "Python" in str(result)
|
||||
|
||||
async def test_nested_directory_structure(self, tmp_path: Path):
|
||||
"""FileSystemProvider should work with nested directories."""
|
||||
# Create nested structure
|
||||
tools = tmp_path / "tools"
|
||||
tools.mkdir()
|
||||
(tools / "greet.py").write_text(
|
||||
"""\
|
||||
from fastmcp.fs import tool
|
||||
|
||||
@tool
|
||||
def greet(name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
"""
|
||||
)
|
||||
|
||||
payments = tools / "payments"
|
||||
payments.mkdir()
|
||||
(payments / "charge.py").write_text(
|
||||
"""\
|
||||
from fastmcp.fs import tool
|
||||
|
||||
@tool
|
||||
def charge(amount: float) -> str:
|
||||
return f"Charged ${amount}"
|
||||
"""
|
||||
)
|
||||
|
||||
provider = FileSystemProvider(tmp_path)
|
||||
mcp = FastMCP("TestServer", providers=[provider])
|
||||
|
||||
async with Client(mcp) as client:
|
||||
tools_list = await client.list_tools()
|
||||
assert len(tools_list) == 2
|
||||
names = {t.name for t in tools_list}
|
||||
assert names == {"greet", "charge"}
|
||||
Loading…
Add table
Add a link
Reference in a new issue