fastmcp/tests/server/test_server.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

436 lines
15 KiB
Python

import os
import warnings
from pathlib import Path
from tempfile import TemporaryDirectory
from textwrap import dedent
from unittest import mock
from mcp.types import TextContent, TextResourceContents
from fastmcp import Client, FastMCP
from fastmcp.server.providers import LocalProvider
from fastmcp.tools import FunctionTool
from fastmcp.tools.base import Tool
from fastmcp.utilities.tests import temporary_settings
class TestCreateServer:
async def test_create_server(self):
mcp = FastMCP(instructions="Server instructions")
assert mcp.name.startswith("FastMCP-")
assert mcp.instructions == "Server instructions"
async def test_change_instruction(self):
mcp = FastMCP(instructions="Server instructions")
assert mcp.instructions == "Server instructions"
mcp.instructions = "New instructions"
assert mcp.instructions == "New instructions"
async def test_non_ascii_description(self):
"""Test that FastMCP handles non-ASCII characters in descriptions correctly"""
mcp = FastMCP()
@mcp.tool(
description=(
"🌟 This tool uses emojis and UTF-8 characters: á é í ó ú ñ 漢字 🎉"
)
)
def hello_world(name: str = "世界") -> str:
return f"¡Hola, {name}! 👋"
async with Client(mcp) as client:
tools = await client.list_tools()
assert len(tools) == 1
tool = tools[0]
assert tool.description is not None
assert "🌟" in tool.description
assert "漢字" in tool.description
assert "🎉" in tool.description
result = await client.call_tool("hello_world", {})
assert result.data == "¡Hola, 世界! 👋"
class TestServerDelegation:
"""Test that FastMCP properly delegates to LocalProvider."""
async def test_tool_decorator_delegates_to_local_provider(self):
"""Test that @mcp.tool registers with the local provider."""
mcp = FastMCP()
@mcp.tool
def my_tool() -> str:
return "result"
# Verify the tool is in the local provider
tool = await mcp._local_provider.get_tool("my_tool")
assert tool is not None
assert tool.name == "my_tool"
async def test_resource_decorator_delegates_to_local_provider(self):
"""Test that @mcp.resource registers with the local provider."""
mcp = FastMCP()
@mcp.resource("resource://test")
def my_resource() -> str:
return "content"
# Verify the resource is in the local provider
resource = await mcp._local_provider.get_resource("resource://test")
assert resource is not None
async def test_prompt_decorator_delegates_to_local_provider(self):
"""Test that @mcp.prompt registers with the local provider."""
mcp = FastMCP()
@mcp.prompt
def my_prompt() -> str:
return "prompt content"
# Verify the prompt is in the local provider
prompt = await mcp._local_provider.get_prompt("my_prompt")
assert prompt is not None
assert prompt.name == "my_prompt"
async def test_add_tool_delegates_to_local_provider(self):
"""Test that mcp.add_tool() registers with the local provider."""
mcp = FastMCP()
def standalone_tool() -> str:
return "result"
mcp.add_tool(FunctionTool.from_function(standalone_tool))
# Verify the tool is in the local provider
tool = await mcp._local_provider.get_tool("standalone_tool")
assert tool is not None
assert tool.name == "standalone_tool"
async def test_get_tools_includes_local_provider_tools(self):
"""Test that get_tools() returns tools from local provider."""
mcp = FastMCP()
@mcp.tool
def local_tool() -> str:
return "local"
tools = await mcp.list_tools()
assert any(t.name == "local_tool" for t in tools)
class TestLocalProviderProperty:
"""Test the public local_provider property."""
async def test_local_provider_returns_local_provider(self):
mcp = FastMCP()
assert isinstance(mcp.local_provider, LocalProvider)
assert mcp.local_provider is mcp._local_provider
async def test_remove_tool_via_local_provider(self):
mcp = FastMCP()
@mcp.tool
def my_tool() -> str:
return "result"
assert await mcp.local_provider.get_tool("my_tool") is not None
mcp.local_provider.remove_tool("my_tool")
tools = await mcp.list_tools()
assert not any(t.name == "my_tool" for t in tools)
async def test_remove_resource_via_local_provider(self):
mcp = FastMCP()
@mcp.resource("resource://test")
def my_resource() -> str:
return "data"
mcp.local_provider.remove_resource("resource://test")
resources = await mcp.list_resources()
assert not any(r.uri == "resource://test" for r in resources)
async def test_remove_prompt_via_local_provider(self):
mcp = FastMCP()
@mcp.prompt
def my_prompt() -> str:
return "hello"
mcp.local_provider.remove_prompt("my_prompt")
prompts = await mcp.list_prompts()
assert not any(p.name == "my_prompt" for p in prompts)
class TestRemoveToolDeprecation:
async def test_remove_tool_emits_deprecation_warning(self):
mcp = FastMCP()
@mcp.tool
def my_tool() -> str:
return "result"
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
mcp.remove_tool("my_tool")
assert len(w) == 1
assert issubclass(w[0].category, DeprecationWarning)
assert "local_provider" in str(w[0].message)
async def test_remove_tool_still_works(self):
mcp = FastMCP()
@mcp.tool
def my_tool() -> str:
return "result"
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
mcp.remove_tool("my_tool")
tools = await mcp.list_tools()
assert not any(t.name == "my_tool" for t in tools)
class TestResourcePrefixMounting:
"""Test resource prefixing in mounted servers."""
async def test_mounted_server_resource_prefixing(self):
"""Test that resources in mounted servers use the correct prefix format."""
# Create a server with resources
server = FastMCP(name="ResourceServer")
@server.resource("resource://test-resource")
def get_resource():
return "Resource content"
@server.resource("resource:///absolute/path")
def get_absolute_resource():
return "Absolute resource content"
@server.resource("resource://{param}/template")
def get_template_resource(param: str):
return f"Template resource with {param}"
# Create a main server and mount the resource server
main_server = FastMCP(name="MainServer")
main_server.mount(server, "prefix")
# Check that the resources are mounted with the correct prefixes
resources = await main_server.list_resources()
templates = await main_server.list_resource_templates()
assert any(str(r.uri) == "resource://prefix/test-resource" for r in resources)
assert any(str(r.uri) == "resource://prefix//absolute/path" for r in resources)
assert any(
t.uri_template == "resource://prefix/{param}/template" for t in templates
)
# Test that prefixed resources can be accessed
async with Client(main_server) as client:
# Regular resource
result = await client.read_resource("resource://prefix/test-resource")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Resource content"
# Absolute path resource
result = await client.read_resource("resource://prefix//absolute/path")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Absolute resource content"
# Template resource
result = await client.read_resource(
"resource://prefix/param-value/template"
)
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Template resource with param-value"
class TestSettingsFromEnvironment:
async def test_server_starts_without_auth(self):
"""Test that server starts without auth configured."""
from fastmcp.client.transports import PythonStdioTransport
script = dedent("""
import fastmcp
mcp = fastmcp.FastMCP("TestServer")
mcp.run()
""")
with TemporaryDirectory() as temp_dir:
server_file = Path(temp_dir) / "server.py"
server_file.write_text(script)
transport: PythonStdioTransport = PythonStdioTransport(
script_path=server_file
)
async with Client[PythonStdioTransport](transport=transport) as client:
tools = await client.list_tools()
assert tools == []
class TestAbstractCollectionTypes:
"""Test that FastMCP accepts abstract collection types from collections.abc."""
async def test_fastmcp_init_with_tuples(self):
"""Test FastMCP accepts tuples for sequence parameters."""
def dummy_tool() -> str:
return "test"
# Test with tuples and other abstract types
mcp = FastMCP(
"test",
middleware=(), # Empty tuple
tools=(Tool.from_function(dummy_tool),), # Tuple of tools
)
mcp.enable(tags={"tag1", "tag2"}, only=True)
mcp.disable(tags={"tag3"})
assert mcp is not None
assert mcp.name == "test"
assert isinstance(mcp.middleware, list) # Should be converted to list
async def test_fastmcp_works_with_abstract_types(self):
"""Test that abstract types work end-to-end with a client."""
def greet(name: str) -> str:
return f"Hello, {name}!"
# Create server with tuple of tools
mcp = FastMCP("test", tools=(Tool.from_function(greet),))
# Verify it works with a client
async with Client(mcp) as client:
result = await client.call_tool("greet", {"name": "World"})
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "Hello, World!"
class TestMeta:
"""Test that fastmcp key is always present in meta."""
async def test_tool_tags_in_meta(self):
"""Test that tool tags appear in meta under fastmcp key."""
mcp = FastMCP()
@mcp.tool(tags={"tool-example", "test-tool-tag"})
def sample_tool(x: int) -> int:
"""A sample tool."""
return x * 2
async with Client(mcp) as client:
tools = await client.list_tools()
tool = next(t for t in tools if t.name == "sample_tool")
assert tool.meta is not None
assert set(tool.meta["fastmcp"]["tags"]) == {
"tool-example",
"test-tool-tag",
}
async def test_resource_tags_in_meta(self):
"""Test that resource tags appear in meta under fastmcp key."""
mcp = FastMCP()
@mcp.resource(
uri="test://resource", tags={"resource-example", "test-resource-tag"}
)
def sample_resource() -> str:
"""A sample resource."""
return "resource content"
async with Client(mcp) as client:
resources = await client.list_resources()
resource = next(r for r in resources if str(r.uri) == "test://resource")
assert resource.meta is not None
assert set(resource.meta["fastmcp"]["tags"]) == {
"resource-example",
"test-resource-tag",
}
async def test_resource_template_tags_in_meta(self):
"""Test that resource template tags appear in meta under fastmcp key."""
mcp = FastMCP()
@mcp.resource(
"test://template/{id}", tags={"template-example", "test-template-tag"}
)
def sample_template(id: str) -> str:
"""A sample resource template."""
return f"template content for {id}"
async with Client(mcp) as client:
templates = await client.list_resource_templates()
template = next(
t for t in templates if t.uriTemplate == "test://template/{id}"
)
assert template.meta is not None
assert set(template.meta["fastmcp"]["tags"]) == {
"template-example",
"test-template-tag",
}
async def test_prompt_tags_in_meta(self):
"""Test that prompt tags appear in meta under fastmcp key."""
mcp = FastMCP()
@mcp.prompt(tags={"example", "test-tag"})
def sample_prompt() -> str:
return "Hello, world!"
async with Client(mcp) as client:
prompts = await client.list_prompts()
prompt = next(p for p in prompts if p.name == "sample_prompt")
assert prompt.meta is not None
assert set(prompt.meta["fastmcp"]["tags"]) == {"example", "test-tag"}
class TestShowServerBannerSetting:
"""Test that show_server_banner setting controls banner display."""
async def test_show_banner_defaults_to_setting_true(self):
"""Test that show_banner=None uses the setting (default True)."""
mcp = FastMCP()
with mock.patch.object(mcp, "run_stdio_async") as mock_run:
mock_run.return_value = None
await mcp.run_async(transport="stdio")
mock_run.assert_called_once()
assert mock_run.call_args.kwargs["show_banner"] is True
async def test_show_banner_respects_setting_false(self):
"""Test that show_banner=None uses the setting when False."""
mcp = FastMCP()
with mock.patch.dict(os.environ, {"FASTMCP_SHOW_SERVER_BANNER": "false"}):
with temporary_settings(show_server_banner=False):
with mock.patch.object(mcp, "run_stdio_async") as mock_run:
mock_run.return_value = None
await mcp.run_async(transport="stdio")
mock_run.assert_called_once()
assert mock_run.call_args.kwargs["show_banner"] is False
async def test_show_banner_explicit_true_overrides_setting(self):
"""Test that explicit show_banner=True overrides False setting."""
mcp = FastMCP()
with temporary_settings(show_server_banner=False):
with mock.patch.object(mcp, "run_stdio_async") as mock_run:
mock_run.return_value = None
await mcp.run_async(transport="stdio", show_banner=True)
mock_run.assert_called_once()
assert mock_run.call_args.kwargs["show_banner"] is True
async def test_show_banner_explicit_false_overrides_setting(self):
"""Test that explicit show_banner=False overrides True setting."""
mcp = FastMCP()
with temporary_settings(show_server_banner=True):
with mock.patch.object(mcp, "run_stdio_async") as mock_run:
mock_run.return_value = None
await mcp.run_async(transport="stdio", show_banner=False)
mock_run.assert_called_once()
assert mock_run.call_args.kwargs["show_banner"] is False