mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-28 18:22:07 +02:00
963 lines
32 KiB
Python
963 lines
32 KiB
Python
"""Tests for the inspect.py module."""
|
|
|
|
import importlib.metadata
|
|
|
|
from mcp.server.fastmcp import FastMCP as FastMCP1x
|
|
|
|
import fastmcp
|
|
from fastmcp import Client, FastMCP
|
|
from fastmcp.utilities.inspect import (
|
|
FastMCPInfo,
|
|
InspectFormat,
|
|
ToolInfo,
|
|
format_fastmcp_info,
|
|
format_info,
|
|
format_mcp_info,
|
|
inspect_fastmcp,
|
|
inspect_fastmcp_v1,
|
|
)
|
|
|
|
|
|
class TestFastMCPInfo:
|
|
"""Tests for the FastMCPInfo dataclass."""
|
|
|
|
def test_fastmcp_info_creation(self):
|
|
"""Test that FastMCPInfo can be created with all required fields."""
|
|
tool = ToolInfo(
|
|
key="tool1",
|
|
name="tool1",
|
|
description="Test tool",
|
|
input_schema={},
|
|
output_schema={
|
|
"type": "object",
|
|
"properties": {"result": {"type": "string"}},
|
|
},
|
|
)
|
|
info = FastMCPInfo(
|
|
name="TestServer",
|
|
instructions="Test instructions",
|
|
fastmcp_version="1.0.0",
|
|
mcp_version="1.0.0",
|
|
server_generation=2,
|
|
version="1.0.0",
|
|
tools=[tool],
|
|
prompts=[],
|
|
resources=[],
|
|
templates=[],
|
|
capabilities={"tools": {"listChanged": True}},
|
|
)
|
|
|
|
assert info.name == "TestServer"
|
|
assert info.instructions == "Test instructions"
|
|
assert info.fastmcp_version == "1.0.0"
|
|
assert info.mcp_version == "1.0.0"
|
|
assert info.server_generation == 2
|
|
assert info.version == "1.0.0"
|
|
assert len(info.tools) == 1
|
|
assert info.tools[0].name == "tool1"
|
|
assert info.capabilities == {"tools": {"listChanged": True}}
|
|
|
|
def test_fastmcp_info_with_none_instructions(self):
|
|
"""Test that FastMCPInfo works with None instructions."""
|
|
info = FastMCPInfo(
|
|
name="TestServer",
|
|
instructions=None,
|
|
fastmcp_version="1.0.0",
|
|
mcp_version="1.0.0",
|
|
server_generation=2,
|
|
version="1.0.0",
|
|
tools=[],
|
|
prompts=[],
|
|
resources=[],
|
|
templates=[],
|
|
capabilities={},
|
|
)
|
|
|
|
assert info.instructions is None
|
|
|
|
|
|
class TestGetFastMCPInfo:
|
|
"""Tests for the get_fastmcp_info function."""
|
|
|
|
async def test_empty_server(self):
|
|
"""Test get_fastmcp_info with an empty server."""
|
|
mcp = FastMCP("EmptyServer")
|
|
|
|
info = await inspect_fastmcp(mcp)
|
|
|
|
assert info.name == "EmptyServer"
|
|
assert info.instructions is None
|
|
assert info.fastmcp_version == fastmcp.__version__
|
|
assert info.mcp_version == importlib.metadata.version("mcp")
|
|
assert info.server_generation == 2 # v2 server
|
|
assert info.version == fastmcp.__version__
|
|
assert info.tools == []
|
|
assert info.prompts == []
|
|
assert info.resources == []
|
|
assert info.templates == []
|
|
assert "tools" in info.capabilities
|
|
assert "resources" in info.capabilities
|
|
assert "prompts" in info.capabilities
|
|
assert "logging" in info.capabilities
|
|
|
|
async def test_server_with_instructions(self):
|
|
"""Test get_fastmcp_info with a server that has instructions."""
|
|
mcp = FastMCP("InstructionsServer", instructions="Test instructions")
|
|
info = await inspect_fastmcp(mcp)
|
|
assert info.instructions == "Test instructions"
|
|
|
|
async def test_server_with_version(self):
|
|
"""Test get_fastmcp_info with a server that has a version."""
|
|
mcp = FastMCP("VersionServer", version="1.2.3")
|
|
info = await inspect_fastmcp(mcp)
|
|
assert info.version == "1.2.3"
|
|
|
|
async def test_server_with_tools(self):
|
|
"""Test get_fastmcp_info with a server that has tools."""
|
|
mcp = FastMCP("ToolServer")
|
|
|
|
@mcp.tool
|
|
def add_numbers(a: int, b: int) -> int:
|
|
return a + b
|
|
|
|
@mcp.tool
|
|
def greet(name: str) -> str:
|
|
return f"Hello, {name}!"
|
|
|
|
info = await inspect_fastmcp(mcp)
|
|
|
|
assert info.name == "ToolServer"
|
|
assert len(info.tools) == 2
|
|
tool_names = [tool.name for tool in info.tools]
|
|
assert "add_numbers" in tool_names
|
|
assert "greet" in tool_names
|
|
|
|
async def test_server_with_resources(self):
|
|
"""Test get_fastmcp_info with a server that has resources."""
|
|
mcp = FastMCP("ResourceServer")
|
|
|
|
@mcp.resource("resource://static")
|
|
def get_static_data() -> str:
|
|
return "Static data"
|
|
|
|
@mcp.resource("resource://dynamic/{param}")
|
|
def get_dynamic_data(param: str) -> str:
|
|
return f"Dynamic data: {param}"
|
|
|
|
info = await inspect_fastmcp(mcp)
|
|
|
|
assert info.name == "ResourceServer"
|
|
assert len(info.resources) == 1 # Static resource
|
|
assert len(info.templates) == 1 # Dynamic resource becomes template
|
|
resource_uris = [res.uri for res in info.resources]
|
|
template_uris = [tmpl.uri_template for tmpl in info.templates]
|
|
assert "resource://static" in resource_uris
|
|
assert "resource://dynamic/{param}" in template_uris
|
|
|
|
async def test_server_with_prompts(self):
|
|
"""Test get_fastmcp_info with a server that has prompts."""
|
|
mcp = FastMCP("PromptServer")
|
|
|
|
@mcp.prompt
|
|
def analyze_data(data: str) -> list:
|
|
return [{"role": "user", "content": f"Analyze: {data}"}]
|
|
|
|
@mcp.prompt("custom_prompt")
|
|
def custom_analysis(text: str) -> list:
|
|
return [{"role": "user", "content": f"Custom: {text}"}]
|
|
|
|
info = await inspect_fastmcp(mcp)
|
|
|
|
assert info.name == "PromptServer"
|
|
assert len(info.prompts) == 2
|
|
prompt_names = [prompt.name for prompt in info.prompts]
|
|
assert "analyze_data" in prompt_names
|
|
assert "custom_prompt" in prompt_names
|
|
|
|
async def test_comprehensive_server(self):
|
|
"""Test get_fastmcp_info with a server that has all component types."""
|
|
mcp = FastMCP("ComprehensiveServer", instructions="A server with everything")
|
|
|
|
# Add a tool
|
|
@mcp.tool
|
|
def calculate(x: int, y: int) -> int:
|
|
return x * y
|
|
|
|
# Add a resource
|
|
@mcp.resource("resource://data")
|
|
def get_data() -> str:
|
|
return "Some data"
|
|
|
|
# Add a template
|
|
@mcp.resource("resource://item/{id}")
|
|
def get_item(id: str) -> str:
|
|
return f"Item {id}"
|
|
|
|
# Add a prompt
|
|
@mcp.prompt
|
|
def analyze(content: str) -> list:
|
|
return [{"role": "user", "content": content}]
|
|
|
|
info = await inspect_fastmcp(mcp)
|
|
|
|
assert info.name == "ComprehensiveServer"
|
|
assert info.instructions == "A server with everything"
|
|
assert info.fastmcp_version == fastmcp.__version__
|
|
|
|
# Check all components are present
|
|
assert len(info.tools) == 1
|
|
tool_names = [tool.name for tool in info.tools]
|
|
assert "calculate" in tool_names
|
|
|
|
assert len(info.resources) == 1
|
|
resource_uris = [res.uri for res in info.resources]
|
|
assert "resource://data" in resource_uris
|
|
|
|
assert len(info.templates) == 1
|
|
template_uris = [tmpl.uri_template for tmpl in info.templates]
|
|
assert "resource://item/{id}" in template_uris
|
|
|
|
assert len(info.prompts) == 1
|
|
prompt_names = [prompt.name for prompt in info.prompts]
|
|
assert "analyze" in prompt_names
|
|
|
|
# Check capabilities
|
|
assert "tools" in info.capabilities
|
|
assert "resources" in info.capabilities
|
|
assert "prompts" in info.capabilities
|
|
assert "logging" in info.capabilities
|
|
|
|
async def test_server_no_instructions(self):
|
|
"""Test get_fastmcp_info with a server that has no instructions."""
|
|
mcp = FastMCP("NoInstructionsServer")
|
|
|
|
info = await inspect_fastmcp(mcp)
|
|
|
|
assert info.name == "NoInstructionsServer"
|
|
assert info.instructions is None
|
|
|
|
async def test_server_with_client_integration(self):
|
|
"""Test that the extracted info matches what a client would see."""
|
|
mcp = FastMCP("IntegrationServer")
|
|
|
|
@mcp.tool
|
|
def test_tool() -> str:
|
|
return "test"
|
|
|
|
@mcp.resource("resource://test")
|
|
def test_resource() -> str:
|
|
return "test resource"
|
|
|
|
@mcp.prompt
|
|
def test_prompt() -> list:
|
|
return [{"role": "user", "content": "test"}]
|
|
|
|
# Get info using our function
|
|
info = await inspect_fastmcp(mcp)
|
|
|
|
# Verify using client
|
|
async with Client(mcp) as client:
|
|
tools = await client.list_tools()
|
|
resources = await client.list_resources()
|
|
prompts = await client.list_prompts()
|
|
|
|
assert len(info.tools) == len(tools)
|
|
assert len(info.resources) == len(resources)
|
|
assert len(info.prompts) == len(prompts)
|
|
|
|
assert info.tools[0].name == tools[0].name
|
|
assert info.resources[0].uri == str(resources[0].uri)
|
|
assert info.prompts[0].name == prompts[0].name
|
|
|
|
|
|
class TestFastMCP1xCompatibility:
|
|
"""Tests for FastMCP 1.x compatibility."""
|
|
|
|
async def test_fastmcp1x_empty_server(self):
|
|
"""Test get_fastmcp_info_v1 with an empty FastMCP1x server."""
|
|
mcp = FastMCP1x("Test1x")
|
|
|
|
info = await inspect_fastmcp_v1(mcp)
|
|
|
|
assert info.name == "Test1x"
|
|
assert info.instructions is None
|
|
assert info.fastmcp_version == fastmcp.__version__ # CLI version
|
|
assert info.mcp_version == importlib.metadata.version("mcp")
|
|
assert info.server_generation == 1 # v1 server
|
|
assert info.version is None
|
|
assert info.tools == []
|
|
assert info.prompts == []
|
|
assert info.resources == []
|
|
assert info.templates == [] # No templates added in this test
|
|
assert "tools" in info.capabilities
|
|
|
|
async def test_fastmcp1x_with_tools(self):
|
|
"""Test get_fastmcp_info_v1 with a FastMCP1x server that has tools."""
|
|
mcp = FastMCP1x("Test1x")
|
|
|
|
@mcp.tool()
|
|
def add_numbers(a: int, b: int) -> int:
|
|
return a + b
|
|
|
|
@mcp.tool()
|
|
def greet(name: str) -> str:
|
|
return f"Hello, {name}!"
|
|
|
|
info = await inspect_fastmcp_v1(mcp)
|
|
|
|
assert info.name == "Test1x"
|
|
assert len(info.tools) == 2
|
|
tool_names = [tool.name for tool in info.tools]
|
|
assert "add_numbers" in tool_names
|
|
assert "greet" in tool_names
|
|
|
|
async def test_fastmcp1x_with_resources(self):
|
|
"""Test get_fastmcp_info_v1 with a FastMCP1x server that has resources."""
|
|
mcp = FastMCP1x("Test1x")
|
|
|
|
@mcp.resource("resource://data")
|
|
def get_data() -> str:
|
|
return "Some data"
|
|
|
|
info = await inspect_fastmcp_v1(mcp)
|
|
|
|
assert info.name == "Test1x"
|
|
assert len(info.resources) == 1
|
|
resource_uris = [res.uri for res in info.resources]
|
|
assert "resource://data" in resource_uris
|
|
assert len(info.templates) == 0 # No templates added in this test
|
|
assert info.server_generation == 1 # v1 server
|
|
|
|
async def test_fastmcp1x_with_prompts(self):
|
|
"""Test get_fastmcp_info_v1 with a FastMCP1x server that has prompts."""
|
|
mcp = FastMCP1x("Test1x")
|
|
|
|
@mcp.prompt("analyze")
|
|
def analyze_data(data: str) -> list:
|
|
return [{"role": "user", "content": f"Analyze: {data}"}]
|
|
|
|
info = await inspect_fastmcp_v1(mcp)
|
|
|
|
assert info.name == "Test1x"
|
|
assert len(info.prompts) == 1
|
|
prompt_names = [prompt.name for prompt in info.prompts]
|
|
assert "analyze" in prompt_names
|
|
|
|
async def test_dispatcher_with_fastmcp1x(self):
|
|
"""Test that the main get_fastmcp_info function correctly dispatches to v1."""
|
|
mcp = FastMCP1x("Test1x")
|
|
|
|
@mcp.tool()
|
|
def test_tool() -> str:
|
|
return "test"
|
|
|
|
info = await inspect_fastmcp(mcp)
|
|
|
|
assert info.name == "Test1x"
|
|
assert len(info.tools) == 1
|
|
tool_names = [tool.name for tool in info.tools]
|
|
assert "test_tool" in tool_names
|
|
assert len(info.templates) == 0 # No templates added in this test
|
|
assert info.server_generation == 1 # v1 server
|
|
|
|
async def test_dispatcher_with_fastmcp2x(self):
|
|
"""Test that the main get_fastmcp_info function correctly dispatches to v2."""
|
|
mcp = FastMCP("Test2x")
|
|
|
|
@mcp.tool
|
|
def test_tool() -> str:
|
|
return "test"
|
|
|
|
info = await inspect_fastmcp(mcp)
|
|
|
|
assert info.name == "Test2x"
|
|
assert len(info.tools) == 1
|
|
tool_names = [tool.name for tool in info.tools]
|
|
assert "test_tool" in tool_names
|
|
|
|
async def test_fastmcp1x_vs_fastmcp2x_comparison(self):
|
|
"""Test that both versions can be inspected and compared."""
|
|
mcp1x = FastMCP1x("Test1x")
|
|
mcp2x = FastMCP("Test2x")
|
|
|
|
@mcp1x.tool()
|
|
def tool1x() -> str:
|
|
return "1x"
|
|
|
|
@mcp2x.tool
|
|
def tool2x() -> str:
|
|
return "2x"
|
|
|
|
info1x = await inspect_fastmcp(mcp1x)
|
|
info2x = await inspect_fastmcp(mcp2x)
|
|
|
|
assert info1x.name == "Test1x"
|
|
assert info2x.name == "Test2x"
|
|
assert len(info1x.tools) == 1
|
|
assert len(info2x.tools) == 1
|
|
|
|
tool1x_names = [tool.name for tool in info1x.tools]
|
|
tool2x_names = [tool.name for tool in info2x.tools]
|
|
assert "tool1x" in tool1x_names
|
|
assert "tool2x" in tool2x_names
|
|
|
|
# Check server versions
|
|
assert info1x.server_generation == 1 # v1
|
|
assert info2x.server_generation == 2 # v2
|
|
assert info1x.version is None
|
|
assert info2x.version == fastmcp.__version__
|
|
|
|
# No templates added in these tests
|
|
assert len(info1x.templates) == 0
|
|
assert len(info2x.templates) == 0
|
|
|
|
|
|
class TestInspectWithTagFiltering:
|
|
"""Tests for inspect functionality with include_tags and exclude_tags."""
|
|
|
|
async def test_inspect_with_include_tags_filters_tools(self):
|
|
"""Test that inspect respects include_tags for tools."""
|
|
mcp = FastMCP("TaggedServer", include_tags={"api"})
|
|
|
|
@mcp.tool(tags={"api"})
|
|
def api_tool() -> str:
|
|
"""API tool."""
|
|
return "api"
|
|
|
|
@mcp.tool(tags={"internal"})
|
|
def internal_tool() -> str:
|
|
"""Internal tool."""
|
|
return "internal"
|
|
|
|
@mcp.tool(tags={"api", "internal"})
|
|
def mixed_tool() -> str:
|
|
"""Mixed tool."""
|
|
return "mixed"
|
|
|
|
info = await inspect_fastmcp(mcp)
|
|
|
|
# Only tools with 'api' tag should be included
|
|
assert len(info.tools) == 2
|
|
tool_names = [tool.name for tool in info.tools]
|
|
assert "api_tool" in tool_names
|
|
assert "mixed_tool" in tool_names
|
|
assert "internal_tool" not in tool_names
|
|
|
|
async def test_inspect_with_exclude_tags_filters_tools(self):
|
|
"""Test that inspect respects exclude_tags for tools."""
|
|
mcp = FastMCP("TaggedServer", exclude_tags={"internal"})
|
|
|
|
@mcp.tool(tags={"api"})
|
|
def api_tool() -> str:
|
|
"""API tool."""
|
|
return "api"
|
|
|
|
@mcp.tool(tags={"internal"})
|
|
def internal_tool() -> str:
|
|
"""Internal tool."""
|
|
return "internal"
|
|
|
|
@mcp.tool(tags={"api", "internal"})
|
|
def mixed_tool() -> str:
|
|
"""Mixed tool."""
|
|
return "mixed"
|
|
|
|
info = await inspect_fastmcp(mcp)
|
|
|
|
# Only tools without 'internal' tag should be included
|
|
assert len(info.tools) == 1
|
|
tool_names = [tool.name for tool in info.tools]
|
|
assert "api_tool" in tool_names
|
|
assert "internal_tool" not in tool_names
|
|
assert "mixed_tool" not in tool_names
|
|
|
|
async def test_inspect_with_include_tags_filters_prompts(self):
|
|
"""Test that inspect respects include_tags for prompts."""
|
|
mcp = FastMCP("TaggedServer", include_tags={"user-facing"})
|
|
|
|
@mcp.prompt(tags={"user-facing"})
|
|
def user_prompt(text: str) -> list:
|
|
"""User prompt."""
|
|
return [{"role": "user", "content": text}]
|
|
|
|
@mcp.prompt(tags={"admin"})
|
|
def admin_prompt(text: str) -> list:
|
|
"""Admin prompt."""
|
|
return [{"role": "user", "content": text}]
|
|
|
|
info = await inspect_fastmcp(mcp)
|
|
|
|
# Only prompts with 'user-facing' tag should be included
|
|
assert len(info.prompts) == 1
|
|
prompt_names = [prompt.name for prompt in info.prompts]
|
|
assert "user_prompt" in prompt_names
|
|
assert "admin_prompt" not in prompt_names
|
|
|
|
async def test_inspect_with_include_tags_filters_resources(self):
|
|
"""Test that inspect respects include_tags for resources."""
|
|
mcp = FastMCP("TaggedServer", include_tags={"public"})
|
|
|
|
@mcp.resource("resource://public", tags={"public"})
|
|
def public_resource() -> str:
|
|
"""Public resource."""
|
|
return "public data"
|
|
|
|
@mcp.resource("resource://private", tags={"private"})
|
|
def private_resource() -> str:
|
|
"""Private resource."""
|
|
return "private data"
|
|
|
|
info = await inspect_fastmcp(mcp)
|
|
|
|
# Only resources with 'public' tag should be included
|
|
assert len(info.resources) == 1
|
|
resource_uris = [res.uri for res in info.resources]
|
|
assert "resource://public" in resource_uris
|
|
assert "resource://private" not in resource_uris
|
|
|
|
async def test_inspect_with_include_tags_filters_templates(self):
|
|
"""Test that inspect respects include_tags for resource templates."""
|
|
mcp = FastMCP("TaggedServer", include_tags={"public"})
|
|
|
|
@mcp.resource("resource://public/{id}", tags={"public"})
|
|
def public_template(id: str) -> str:
|
|
"""Public template."""
|
|
return f"public {id}"
|
|
|
|
@mcp.resource("resource://private/{id}", tags={"private"})
|
|
def private_template(id: str) -> str:
|
|
"""Private template."""
|
|
return f"private {id}"
|
|
|
|
info = await inspect_fastmcp(mcp)
|
|
|
|
# Only templates with 'public' tag should be included
|
|
assert len(info.templates) == 1
|
|
template_uris = [tmpl.uri_template for tmpl in info.templates]
|
|
assert "resource://public/{id}" in template_uris
|
|
assert "resource://private/{id}" not in template_uris
|
|
|
|
async def test_inspect_with_multiple_component_types_filtered(self):
|
|
"""Test that inspect filters all component types consistently."""
|
|
mcp = FastMCP("TaggedServer", include_tags={"api"})
|
|
|
|
@mcp.tool(tags={"api"})
|
|
def api_tool() -> str:
|
|
return "api"
|
|
|
|
@mcp.tool(tags={"internal"})
|
|
def internal_tool() -> str:
|
|
return "internal"
|
|
|
|
@mcp.prompt(tags={"api"})
|
|
def api_prompt() -> list:
|
|
return [{"role": "user", "content": "api"}]
|
|
|
|
@mcp.prompt(tags={"internal"})
|
|
def internal_prompt() -> list:
|
|
return [{"role": "user", "content": "internal"}]
|
|
|
|
@mcp.resource("resource://api", tags={"api"})
|
|
def api_resource() -> str:
|
|
return "api"
|
|
|
|
@mcp.resource("resource://internal", tags={"internal"})
|
|
def internal_resource() -> str:
|
|
return "internal"
|
|
|
|
info = await inspect_fastmcp(mcp)
|
|
|
|
# All component types should be filtered
|
|
assert len(info.tools) == 1
|
|
assert len(info.prompts) == 1
|
|
assert len(info.resources) == 1
|
|
assert info.tools[0].name == "api_tool"
|
|
assert info.prompts[0].name == "api_prompt"
|
|
assert info.resources[0].uri == "resource://api"
|
|
|
|
async def test_format_mcp_respects_filtering(self):
|
|
"""Test that format_mcp_info also respects tag filtering."""
|
|
mcp = FastMCP("TaggedServer", include_tags={"api"})
|
|
|
|
@mcp.tool(tags={"api"})
|
|
def api_tool() -> str:
|
|
"""API tool."""
|
|
return "api"
|
|
|
|
@mcp.tool(tags={"internal"})
|
|
def internal_tool() -> str:
|
|
"""Internal tool."""
|
|
return "internal"
|
|
|
|
json_bytes = await format_mcp_info(mcp)
|
|
|
|
import json
|
|
|
|
data = json.loads(json_bytes)
|
|
|
|
# Only tools with 'api' tag should be in MCP format
|
|
assert len(data["tools"]) == 1
|
|
assert data["tools"][0]["name"] == "api_tool"
|
|
|
|
async def test_format_fastmcp_respects_filtering(self):
|
|
"""Test that format_fastmcp_info also respects tag filtering."""
|
|
mcp = FastMCP("TaggedServer", include_tags={"api"})
|
|
|
|
@mcp.tool(tags={"api"})
|
|
def api_tool() -> str:
|
|
"""API tool."""
|
|
return "api"
|
|
|
|
@mcp.tool(tags={"internal"})
|
|
def internal_tool() -> str:
|
|
"""Internal tool."""
|
|
return "internal"
|
|
|
|
info = await inspect_fastmcp(mcp)
|
|
json_bytes = await format_fastmcp_info(info)
|
|
|
|
import json
|
|
|
|
data = json.loads(json_bytes)
|
|
|
|
# Only tools with 'api' tag should be in FastMCP format
|
|
assert len(data["tools"]) == 1
|
|
assert data["tools"][0]["name"] == "api_tool"
|
|
|
|
async def test_inspect_mounted_servers_with_tag_filtering(self):
|
|
"""Test that inspect respects tag filtering in mounted servers."""
|
|
# Create child servers with different tag filtering rules
|
|
child1 = FastMCP("child1", include_tags={"admin"})
|
|
|
|
@child1.tool(tags={"admin"})
|
|
def child1_admin_tool() -> str:
|
|
"""Child1 Admin Tool."""
|
|
return "admin"
|
|
|
|
@child1.tool(tags={"public"})
|
|
def child1_public_tool() -> str:
|
|
"""Child1 Public Tool (should be hidden)."""
|
|
return "public"
|
|
|
|
child2 = FastMCP("child2", exclude_tags={"internal"})
|
|
|
|
@child2.tool(tags={"public"})
|
|
def child2_public_tool() -> str:
|
|
"""Child2 Public Tool."""
|
|
return "public"
|
|
|
|
@child2.tool(tags={"internal"})
|
|
def child2_internal_tool() -> str:
|
|
"""Child2 Internal Tool (should be hidden)."""
|
|
return "internal"
|
|
|
|
# Create parent server with its own filtering
|
|
parent = FastMCP("parent", include_tags={"show"})
|
|
|
|
@parent.tool(tags={"show"})
|
|
def parent_show_tool() -> str:
|
|
"""Parent Show Tool."""
|
|
return "show"
|
|
|
|
@parent.tool()
|
|
def parent_hide_tool() -> str:
|
|
"""Parent Hide Tool (should be hidden)."""
|
|
return "hide"
|
|
|
|
# Mount children
|
|
parent.mount(child1, prefix="c1")
|
|
parent.mount(child2, prefix="c2")
|
|
|
|
# Inspect the parent server
|
|
info = await inspect_fastmcp(parent)
|
|
|
|
# Verify parent's filtering is applied to local tools only
|
|
parent_tool_keys = [
|
|
t.key
|
|
for t in info.tools
|
|
if not t.key.startswith("c1_") and not t.key.startswith("c2_")
|
|
]
|
|
assert "parent_show_tool" in parent_tool_keys
|
|
assert "parent_hide_tool" not in parent_tool_keys
|
|
|
|
# Verify child1's filtering is preserved (only admin tools)
|
|
child1_tool_keys = [t.key for t in info.tools if t.key.startswith("c1_")]
|
|
assert "c1_child1_admin_tool" in child1_tool_keys
|
|
assert "c1_child1_public_tool" not in child1_tool_keys
|
|
|
|
# Verify child2's filtering is preserved (exclude internal tools)
|
|
child2_tool_keys = [t.key for t in info.tools if t.key.startswith("c2_")]
|
|
assert "c2_child2_public_tool" in child2_tool_keys
|
|
assert "c2_child2_internal_tool" not in child2_tool_keys
|
|
|
|
async def test_inspect_mounted_servers_with_resources_filtering(self):
|
|
"""Test that inspect respects tag filtering for resources in mounted servers."""
|
|
# Create child server with resource filtering
|
|
child = FastMCP("child", include_tags={"public"})
|
|
|
|
@child.resource("resource://child/public", tags={"public"})
|
|
def child_public_resource() -> str:
|
|
"""Child Public Resource."""
|
|
return "public"
|
|
|
|
@child.resource("resource://child/private", tags={"private"})
|
|
def child_private_resource() -> str:
|
|
"""Child Private Resource (should be hidden)."""
|
|
return "private"
|
|
|
|
# Create parent server with its own filtering
|
|
parent = FastMCP("parent", include_tags={"show"})
|
|
|
|
@parent.resource("resource://parent/show", tags={"show"})
|
|
def parent_show_resource() -> str:
|
|
"""Parent Show Resource."""
|
|
return "show"
|
|
|
|
@parent.resource("resource://parent/hide")
|
|
def parent_hide_resource() -> str:
|
|
"""Parent Hide Resource (should be hidden)."""
|
|
return "hide"
|
|
|
|
# Mount child
|
|
parent.mount(child, prefix="c")
|
|
|
|
# Inspect the parent server
|
|
info = await inspect_fastmcp(parent)
|
|
|
|
# Verify parent's resources
|
|
parent_resource_uris = [
|
|
r.uri for r in info.resources if not r.uri.startswith("resource://c/")
|
|
]
|
|
assert "resource://parent/show" in parent_resource_uris
|
|
assert "resource://parent/hide" not in parent_resource_uris
|
|
|
|
# Verify child's filtering is preserved
|
|
child_resource_uris = [
|
|
r.uri for r in info.resources if r.uri.startswith("resource://c/")
|
|
]
|
|
assert "resource://c/child/public" in child_resource_uris
|
|
assert "resource://c/child/private" not in child_resource_uris
|
|
|
|
async def test_inspect_mounted_servers_with_prompts_filtering(self):
|
|
"""Test that inspect respects tag filtering for prompts in mounted servers."""
|
|
# Create child server with prompt filtering
|
|
child = FastMCP("child", exclude_tags={"internal"})
|
|
|
|
@child.prompt(tags={"user"})
|
|
def child_user_prompt() -> list:
|
|
"""Child User Prompt."""
|
|
return [{"role": "user", "content": "user"}]
|
|
|
|
@child.prompt(tags={"internal"})
|
|
def child_internal_prompt() -> list:
|
|
"""Child Internal Prompt (should be hidden)."""
|
|
return [{"role": "user", "content": "internal"}]
|
|
|
|
# Create parent server with its own filtering
|
|
parent = FastMCP("parent", include_tags={"api"})
|
|
|
|
@parent.prompt(tags={"api"})
|
|
def parent_api_prompt() -> list:
|
|
"""Parent API Prompt."""
|
|
return [{"role": "user", "content": "api"}]
|
|
|
|
@parent.prompt()
|
|
def parent_other_prompt() -> list:
|
|
"""Parent Other Prompt (should be hidden)."""
|
|
return [{"role": "user", "content": "other"}]
|
|
|
|
# Mount child
|
|
parent.mount(child, prefix="c")
|
|
|
|
# Inspect the parent server
|
|
info = await inspect_fastmcp(parent)
|
|
|
|
# Verify parent's prompts
|
|
parent_prompt_keys = [p.key for p in info.prompts if not p.key.startswith("c_")]
|
|
assert "parent_api_prompt" in parent_prompt_keys
|
|
assert "parent_other_prompt" not in parent_prompt_keys
|
|
|
|
# Verify child's filtering is preserved
|
|
child_prompt_keys = [p.key for p in info.prompts if p.key.startswith("c_")]
|
|
assert "c_child_user_prompt" in child_prompt_keys
|
|
assert "c_child_internal_prompt" not in child_prompt_keys
|
|
|
|
|
|
class TestFormatFunctions:
|
|
"""Tests for the formatting functions."""
|
|
|
|
async def test_format_fastmcp_info(self):
|
|
"""Test formatting as FastMCP-specific JSON."""
|
|
mcp = FastMCP("TestServer", instructions="Test instructions", version="1.2.3")
|
|
|
|
@mcp.tool
|
|
def test_tool(x: int) -> dict:
|
|
"""A test tool."""
|
|
return {"result": x * 2}
|
|
|
|
info = await inspect_fastmcp(mcp)
|
|
json_bytes = await format_fastmcp_info(info)
|
|
|
|
# Verify it's valid JSON
|
|
import json
|
|
|
|
data = json.loads(json_bytes)
|
|
|
|
# Check FastMCP-specific fields are present
|
|
assert "server" in data
|
|
assert data["server"]["name"] == "TestServer"
|
|
assert data["server"]["instructions"] == "Test instructions"
|
|
assert data["server"]["generation"] == 2 # v2 server
|
|
assert data["server"]["version"] == "1.2.3"
|
|
assert "capabilities" in data["server"]
|
|
|
|
# Check environment information
|
|
assert "environment" in data
|
|
assert data["environment"]["fastmcp"] == fastmcp.__version__
|
|
assert data["environment"]["mcp"] == importlib.metadata.version("mcp")
|
|
|
|
# Check tools
|
|
assert len(data["tools"]) == 1
|
|
assert data["tools"][0]["name"] == "test_tool"
|
|
assert data["tools"][0]["enabled"] is True
|
|
assert "tags" in data["tools"][0]
|
|
|
|
async def test_format_mcp_info(self):
|
|
"""Test formatting as MCP protocol JSON."""
|
|
mcp = FastMCP("TestServer", instructions="Test instructions", version="2.0.0")
|
|
|
|
@mcp.tool
|
|
def add(a: int, b: int) -> int:
|
|
"""Add two numbers."""
|
|
return a + b
|
|
|
|
@mcp.prompt
|
|
def test_prompt(name: str) -> list:
|
|
"""Test prompt."""
|
|
return [{"role": "user", "content": f"Hello {name}"}]
|
|
|
|
json_bytes = await format_mcp_info(mcp)
|
|
|
|
# Verify it's valid JSON
|
|
import json
|
|
|
|
data = json.loads(json_bytes)
|
|
|
|
# Check MCP protocol structure with camelCase
|
|
assert "serverInfo" in data
|
|
assert data["serverInfo"]["name"] == "TestServer"
|
|
|
|
# Check server version in MCP format
|
|
assert data["serverInfo"]["version"] == "2.0.0"
|
|
|
|
# MCP format SHOULD have environment fields
|
|
assert "environment" in data
|
|
assert data["environment"]["fastmcp"] == fastmcp.__version__
|
|
assert data["environment"]["mcp"] == importlib.metadata.version("mcp")
|
|
assert "capabilities" in data
|
|
|
|
assert "tools" in data
|
|
assert "prompts" in data
|
|
assert "resources" in data
|
|
assert "resourceTemplates" in data
|
|
|
|
# Check tools have MCP format (camelCase fields)
|
|
assert len(data["tools"]) == 1
|
|
assert data["tools"][0]["name"] == "add"
|
|
assert "inputSchema" in data["tools"][0]
|
|
|
|
# FastMCP-specific fields should not be present
|
|
assert "tags" not in data["tools"][0]
|
|
assert "enabled" not in data["tools"][0]
|
|
|
|
async def test_format_info_with_fastmcp_format(self):
|
|
"""Test format_info with fastmcp format."""
|
|
mcp = FastMCP("TestServer")
|
|
|
|
@mcp.tool
|
|
def test() -> str:
|
|
return "test"
|
|
|
|
# Test with string format
|
|
json_bytes = await format_info(mcp, "fastmcp")
|
|
import json
|
|
|
|
data = json.loads(json_bytes)
|
|
assert data["server"]["name"] == "TestServer"
|
|
assert "tags" in data["tools"][0] # FastMCP-specific field
|
|
|
|
# Test with enum format
|
|
json_bytes = await format_info(mcp, InspectFormat.FASTMCP)
|
|
data = json.loads(json_bytes)
|
|
assert data["server"]["name"] == "TestServer"
|
|
|
|
async def test_format_info_with_mcp_format(self):
|
|
"""Test format_info with mcp format."""
|
|
mcp = FastMCP("TestServer")
|
|
|
|
@mcp.tool
|
|
def test() -> str:
|
|
return "test"
|
|
|
|
json_bytes = await format_info(mcp, "mcp")
|
|
|
|
import json
|
|
|
|
data = json.loads(json_bytes)
|
|
assert "serverInfo" in data
|
|
assert "tools" in data
|
|
assert "inputSchema" in data["tools"][0] # MCP uses camelCase
|
|
|
|
async def test_format_info_requires_format(self):
|
|
"""Test that format_info requires a format parameter."""
|
|
mcp = FastMCP("TestServer")
|
|
|
|
@mcp.tool
|
|
def test() -> str:
|
|
return "test"
|
|
|
|
# Should work with valid formats
|
|
json_bytes = await format_info(mcp, "fastmcp")
|
|
assert json_bytes
|
|
|
|
json_bytes = await format_info(mcp, "mcp")
|
|
assert json_bytes
|
|
|
|
# Should fail with invalid format
|
|
import pytest
|
|
|
|
with pytest.raises(ValueError, match="not a valid InspectFormat"):
|
|
await format_info(mcp, "invalid") # type: ignore
|
|
|
|
async def test_tool_with_output_schema(self):
|
|
"""Test that output_schema is properly extracted and included."""
|
|
mcp = FastMCP("TestServer")
|
|
|
|
@mcp.tool(
|
|
output_schema={
|
|
"type": "object",
|
|
"properties": {
|
|
"result": {"type": "number"},
|
|
"message": {"type": "string"},
|
|
},
|
|
}
|
|
)
|
|
def compute(x: int) -> dict:
|
|
"""Compute something."""
|
|
return {"result": x * 2, "message": f"Doubled {x}"}
|
|
|
|
info = await inspect_fastmcp(mcp)
|
|
|
|
# Check output_schema is captured
|
|
assert len(info.tools) == 1
|
|
assert info.tools[0].output_schema is not None
|
|
assert info.tools[0].output_schema["type"] == "object"
|
|
assert "result" in info.tools[0].output_schema["properties"]
|
|
|
|
# Verify it's included in FastMCP format
|
|
json_bytes = await format_fastmcp_info(info)
|
|
import json
|
|
|
|
data = json.loads(json_bytes)
|
|
# Tools are at the top level, not nested
|
|
assert data["tools"][0]["output_schema"]["type"] == "object"
|