fastmcp/tests/fs/test_discovery.py
Jeremiah Lowin 3a9717e6be
Publish docs for v3.2.0 (#3713)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Jeremiah Lowin <jlowin@users.noreply.github.com>
Co-authored-by: Marvin Context Protocol <41898282+Marvin Context Protocol@users.noreply.github.com>
Co-authored-by: voidborne-d <voidborne-d@users.noreply.github.com>
Co-authored-by: marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: d 🔹 <258577966+voidborne-d@users.noreply.github.com>
Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Co-authored-by: nightcityblade <nightcityblade@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Bill Easton <strawgate@users.noreply.github.com>
Co-authored-by: Sumanshu Nankana <sumanshunankana@gmail.com>
Co-authored-by: Eric Robinson <ericrobinson@indeed.com>
Co-authored-by: Martim Santos <martimfasantos@gmail.com>
Co-authored-by: d 🔹 <liusway405@gmail.com>
Co-authored-by: Matthieu B <66959271+mtthidoteu@users.noreply.github.com>
Co-authored-by: Sascha Buehrle <47737812+saschabuehrle@users.noreply.github.com>
Co-authored-by: Hakancan <142545736+hkc5@users.noreply.github.com>
Co-authored-by: nightcityblade <jackchen@haloailabs.com>
Co-authored-by: Matt Hallowell <17804673+mhallo@users.noreply.github.com>
Co-authored-by: nate nowack <thrast36@gmail.com>
Co-authored-by: Bill Easton <williamseaston@gmail.com>
Co-authored-by: Marcus Shu <46469249+shulkx@users.noreply.github.com>
Co-authored-by: Rushabh Doshi <radoshi@gmail.com>
Co-authored-by: AIKAWA Shigechika <shige@aikawa.jp>
Co-authored-by: Jeremy Simon <simonjer805@gmail.com>
Co-authored-by: Miguel Miranda Dias <7780875+pandego@users.noreply.github.com>
Co-authored-by: Anthony James Padavano <padavano.anthony@gmail.com>
Co-authored-by: Mostafa Kamal <hiremostafa@gmail.com>
Fix auto-close MRE script posting comment without closing (#3386)
Fix WorkOS token scope verification bypass 🤖 Generated with Codex (#3407)
Fix initialize McpError fallthrough 🤖 Generated with Codex (#3413)
Fix transform arg collisions with passthrough params (#3431)
Fix get_* returning None when latest version is disabled (#3439)
Fix get_* returning None when latest version is disabled (#3421)
Fix server lifespan overlap teardown (#3415)
Fix $ref output schema object detection regression (#3420)
resolved annotations (#3429)
Fix async partial callables rejected by iscoroutinefunction (#3438)
Fix async partial callables rejected by iscoroutinefunction (#3423)
fix: add version to components (#3458)
fix: use intent-based flag for OIDC scope patch in load_access_token (#3465)
Fixes #3461
fix: normalize Google scope shorthands and surface valid_scopes (#3477)
fix: resolve ty 0.0.23 type-checking errors and bump pin (#3481)
fix: shield lifespan teardown from cancellation (#3480)
fix: forward custom_route endpoints from mounted servers (#3462)
fix updates _get_additional_http_routes() to traverse providers,
Fixes #3457
fix: remove hardcoded version from CLI help text (#3456)
fix: monty 0.0.8 compatibility, drop external_functions from constructor (#3468)
fix: task test teardown hanging 5s per test (#3499)
Closes #3498
fix: validate workspace path is a directory before cursor install (#3440)
Fixes #3426
fix: handle re.error from malformed URI templates in build_regex (#3501)
fix: reject empty/OIDC-only required_scopes in AzureProvider (#3503)
fix: restrict $ref resolution to local refs only (SSRF/LFI) (#3502)
fix warnings and timeouts (#3504)
close upgrade check issue when build passes (#3505)
Closes #3484
fix: URL-encode path params to prevent SSRF/path traversal (GHSA-vv7q-7jx5-f767) (#3507)
fix: prevent path traversal in skill download (#3493)
fix: prefer IdP-granted scopes over client-requested scopes in OAuthProxy (#3492)
fix: remove unrelated transform and http.py changes from PR scope
fix: remove forced follow_redirects from httpx_client_factory calls (#3496)
fix: stop passing follow_redirects to httpx_client_factory
fix: restore follow_redirects=True for custom httpx client factories
Closes #3509
fix: CSRF double-submit cookie check in consent flow (#3519)
fix: validate server names in install commands (#3522)
fix: use raw strings for regex in pytest.raises match (#3523)
fix: reject refresh tokens used as Bearer access tokens (#3524)
fix: route ResourcesAsTools/PromptsAsTools through server middleware (#3495)
fix: resolve Pyright "Module is not callable" on @tool, @resource, @prompt decorators (#3540)
fix: filter warnings by message in KEY_PREFIX test (#3549)
fix: suppress output schema for ToolResult subclass annotations (#3548)
fix: increase sleep duration in proxy cache tests (#3567)
fix: store absolute token expiry to prevent stale expires_in on reload (#3572)
fix: preserve tool properties named 'title' during schema compression (#3582)
Fix loopback redirect URI port matching per RFC 8252 §7.3 (#3589)
Fix app tool routing: visibility check and middleware propagation (#3591)
Fix query parameter serialization to respect OpenAPI explode/style settings (#3595)
Fix dev apps form: union types, textarea support, JSON parsing (#3597)
fix(google): replace deprecated /oauth2/v1/tokeninfo with /oauth2/v3/userinfo (#3603)
fix: resolve EntraOBOToken dependency injection through MultiAuth (#3609)
fix(docs): correct misleading stateless_http header (#3622)
fix: filesystem provider import machinery (#3626)
Closes #3625 (issues 2, 3, 6)
fix: recover StdioTransport after subprocess exits (#3630)
fix(server): preserve mounted tool task metadata (#3632)
fix: scope deprecation warning filter to FastMCPDeprecationWarning (#3649)
fix imports, add PrefabAppConfig (#3650)
fix: resolve CurrentFastMCP/ctx.fastmcp to child server in mounted background tasks (#3651)
Fix blocking docs issues: chart imports, Select API, Rx consistency (#3652)
closed by default (#3657)
Fix prompt caching middleware missing wrap/unwrap round-trip (#3666)
fix: serialize object query params per OpenAPI style/explode rules (#3662)
Fixes #2857
fix: HTTP request headers not accessible in background task workers (#3631)
fix: restore HTTP headers in worker execution path for background tasks (#3681)
fix: strip discriminator after dereferencing schemas (#3682)
fix: remove stale ty:ignore directives for ty 0.0.26 (#3684)
Fix docs gaps in app provider pages (#3690)
fix: dev apps log panel UX improvements (#3698)
fix dev server empty string args (#3700)
2026-03-30 16:48:30 -04:00

586 lines
19 KiB
Python

"""Tests for filesystem discovery module."""
import sys
from pathlib import Path
from fastmcp.prompts.base import Prompt
from fastmcp.resources.base import Resource
from fastmcp.resources.template import FunctionResourceTemplate, ResourceTemplate
from fastmcp.server.providers.filesystem_discovery import (
discover_and_import,
discover_files,
extract_components,
import_module_from_file,
)
from fastmcp.tools import FunctionTool
from fastmcp.tools.base import Tool
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 components."""
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 objects."""
py_file = tmp_path / "tools.py"
py_file.write_text(
"""\
from fastmcp.tools 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
component = components[0]
assert isinstance(component, FunctionTool)
assert component.name == "greet"
def test_extract_multiple_components(self, tmp_path: Path):
"""Should extract multiple component types."""
py_file = tmp_path / "multi.py"
py_file.write_text(
"""\
from fastmcp.tools import tool
from fastmcp.resources import resource
from fastmcp.prompts import prompt
@tool
def greet(name: str) -> str:
return f"Hello, {name}!"
@resource("config://app")
def get_config() -> dict:
return {}
@prompt
def analyze(topic: str) -> str:
return f"Analyze: {topic}"
"""
)
module = import_module_from_file(py_file)
components = extract_components(module)
assert len(components) == 3
types = {type(c).__name__ for c in components}
assert types == {"FunctionTool", "FunctionResource", "FunctionPrompt"}
def test_extract_skips_private_components(self, tmp_path: Path):
"""Should skip private components (those starting with _)."""
py_file = tmp_path / "private.py"
py_file.write_text(
"""\
from fastmcp.tools import tool
@tool
def public_tool() -> str:
return "public"
# The module attribute starts with _, so it's skipped during discovery
@tool("private_tool_name")
def _private_tool() -> str:
return "private"
"""
)
module = import_module_from_file(py_file)
components = extract_components(module)
# Only public_tool should be found (_private_tool starts with _, so skipped)
assert len(components) == 1
component = components[0]
assert component.name == "public_tool"
def test_extract_resource_template(self, tmp_path: Path):
"""Should extract ResourceTemplate objects."""
py_file = tmp_path / "templates.py"
py_file.write_text(
"""\
from fastmcp.resources import resource
@resource("users://{user_id}/profile")
def get_profile(user_id: str) -> dict:
return {"id": user_id}
"""
)
module = import_module_from_file(py_file)
components = extract_components(module)
assert len(components) == 1
component = components[0]
assert isinstance(component, FunctionResourceTemplate)
assert component.uri_template == "users://{user_id}/profile"
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.tools 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, component = result.components[0]
assert file_path.name == "greet.py"
assert isinstance(component, FunctionTool)
assert component.name == "greet"
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.tools 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
_, component = result.components[0]
assert component.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]
class TestExtractComponentsVersion:
"""Tests for version propagation in extract_components."""
def test_extract_tool_preserves_version(self, tmp_path: Path):
"""Tools discovered from files should have their version attribute set."""
tool_file = tmp_path / "versioned_tool.py"
tool_file.write_text(
"""\
from fastmcp.tools import tool
@tool(version="1.0", description="v1")
def greet_v1(name: str) -> str:
return f"Hi {name}"
@tool(version="2.0", description="v2")
def greet_v2(name: str) -> str:
return f"Hey {name}"
"""
)
module = import_module_from_file(tool_file)
components = extract_components(module)
tools = [c for c in components if isinstance(c, Tool)]
assert len(tools) == 2
versions = {t.version for t in tools}
assert versions == {"1.0", "2.0"}
def test_extract_resource_preserves_version(self, tmp_path: Path):
"""Resources discovered from files should have their version attribute set."""
resource_file = tmp_path / "versioned_resource.py"
resource_file.write_text(
"""\
from fastmcp.resources import resource
@resource("data://config", version="1.0", name="config", description="v1 config")
def config_v1() -> str:
return '{"theme": "light"}'
"""
)
module = import_module_from_file(resource_file)
components = extract_components(module)
resources = [c for c in components if isinstance(c, Resource)]
assert len(resources) == 1
assert resources[0].version == "1.0"
def test_extract_resource_template_preserves_version(self, tmp_path: Path):
"""Resource templates discovered from files should have their version set."""
template_file = tmp_path / "versioned_template.py"
template_file.write_text(
"""\
from fastmcp.resources import resource
@resource("users://{user_id}/profile", version="2.0", description="v2 profile")
def get_profile(user_id: str) -> dict:
return {"id": user_id}
"""
)
module = import_module_from_file(template_file)
components = extract_components(module)
templates = [c for c in components if isinstance(c, ResourceTemplate)]
assert len(templates) == 1
assert templates[0].version == "2.0"
def test_extract_prompt_preserves_version(self, tmp_path: Path):
"""Prompts discovered from files should have their version attribute set."""
prompt_file = tmp_path / "versioned_prompt.py"
prompt_file.write_text(
"""\
from fastmcp.prompts import prompt
@prompt(name="summarize", version="1.0", description="v1 prompt")
def summarize_v1(text: str) -> str:
return f"Summarize: {text}"
"""
)
module = import_module_from_file(prompt_file)
components = extract_components(module)
prompts = [c for c in components if isinstance(c, Prompt)]
assert len(prompts) == 1
assert prompts[0].version == "1.0"
def test_discovered_tool_meta_includes_version(self, tmp_path: Path):
"""get_meta() should include version for tools discovered via filesystem."""
tool_file = tmp_path / "meta_tool.py"
tool_file.write_text(
"""\
from fastmcp.tools import tool
@tool(name="echo", version="3.0", description="Echo tool")
def echo(msg: str) -> str:
return msg
"""
)
module = import_module_from_file(tool_file)
components = extract_components(module)
tool = components[0]
meta = tool.get_meta()
assert meta["fastmcp"]["version"] == "3.0"
def test_unversioned_components_have_no_version(self, tmp_path: Path):
"""Components without version should have version=None."""
tool_file = tmp_path / "no_version_tool.py"
tool_file.write_text(
"""\
from fastmcp.tools import tool
@tool(description="No version")
def my_tool(x: str) -> str:
return x
"""
)
module = import_module_from_file(tool_file)
components = extract_components(module)
assert len(components) == 1
assert components[0].version is None
meta = components[0].get_meta()
assert "version" not in meta["fastmcp"]
class TestImportMachineryFixes:
"""Tests for import machinery correctness: sys.path cleanup, sys.modules safety, package root boundary."""
def test_syspath_not_polluted_after_import(self, tmp_path: Path):
"""sys.path should not contain the file's parent after import_module_from_file returns."""
(tmp_path / "mymod.py").write_text("VALUE = 1")
path_before = list(sys.path)
import_module_from_file(tmp_path / "mymod.py")
assert sys.path == path_before
def test_syspath_not_polluted_after_package_import(self, tmp_path: Path):
"""sys.path should not contain the package root's parent after a package import."""
pkg = tmp_path / "mypkg_syspath"
pkg.mkdir()
(pkg / "__init__.py").write_text("")
(pkg / "mod.py").write_text("VALUE = 2")
path_before = list(sys.path)
import_module_from_file(pkg / "mod.py", provider_root=tmp_path)
assert sys.path == path_before
def test_stdlib_not_shadowed_by_same_named_file(self, tmp_path: Path):
"""A provider file named json.py must not overwrite sys.modules['json']."""
import json as stdlib_json
saved = sys.modules["json"]
try:
(tmp_path / "json.py").write_text(
"from fastmcp.tools import tool\n@tool\ndef parse(): return 'provider'"
)
import_module_from_file(tmp_path / "json.py")
assert sys.modules.get("json") is stdlib_json
finally:
sys.modules["json"] = saved
def test_same_stem_files_get_independent_modules(self, tmp_path: Path):
"""Two files with the same stem in different directories must not collide in sys.modules.
The first-imported file keeps the bare stem key; the second gets a private key.
Both modules must be independently accessible with correct content.
"""
dir_a = tmp_path / "a"
dir_b = tmp_path / "b"
dir_a.mkdir()
dir_b.mkdir()
(dir_a / "helpers.py").write_text("ORIGIN = 'a'")
(dir_b / "helpers.py").write_text("ORIGIN = 'b'")
mod_a = import_module_from_file(dir_a / "helpers.py")
mod_b = import_module_from_file(dir_b / "helpers.py")
assert mod_a.ORIGIN == "a"
assert mod_b.ORIGIN == "b"
# The first module retains the bare stem key; the second uses a private key.
# They must be distinct objects — the second import must not have clobbered the first.
assert mod_a is not mod_b
assert sys.modules.get("helpers") is not mod_b
def test_package_root_bounded_by_provider_root(self, tmp_path: Path):
"""When the provider root is nested inside a larger package, import_module_from_file
with provider_root must not escape into ancestor packages.
The generated module name should be relative to the provider root (e.g. "myprovider.tools"),
not to an ancestor package (e.g. "myproject.myprovider.tools"), and tmp_path (the
ancestor's parent) must not be added to sys.path.
"""
# Use a name that won't collide with any installed package
project = tmp_path / "myproject"
project.mkdir()
(project / "__init__.py").write_text("")
provider = project / "myprovider"
provider.mkdir()
(provider / "__init__.py").write_text("")
(provider / "tools.py").write_text("VALUE = 42")
path_before = set(sys.path)
mod = import_module_from_file(provider / "tools.py", provider_root=provider)
path_after = set(sys.path)
# Module was correctly imported
assert mod.VALUE == 42
# sys.path should not contain tmp_path (the ancestor's grandparent);
# that would only happen if the package root escaped past the provider boundary
assert str(tmp_path) not in (path_after - path_before)
# The module name is bounded to the provider root, not "myproject.myprovider.tools"
assert mod.__name__ == "myprovider.tools"
def test_non_package_reload_returns_updated_content(self, tmp_path: Path):
"""Re-importing a non-package file should reflect file changes (exec_module path)."""
f = tmp_path / "reloadable_np.py"
f.write_text("VALUE = 'original'")
mod = import_module_from_file(f)
assert mod.VALUE == "original"
f.write_text("VALUE = 'updated'")
mod2 = import_module_from_file(f)
assert mod2.VALUE == "updated"