mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-20 04:24:17 +02:00
Generates Python scripts from MCP server tools for progressive discovery by agents. Usage: # No auth fastmcp generate https://api.example.com/mcp # OAuth fastmcp generate https://api.example.com/mcp --auth oauth # Environment variable fastmcp generate https://api.example.com/mcp --auth '$MY_TOKEN' # Embedded token fastmcp generate https://api.example.com/mcp --auth "sk-token-123" Each tool becomes a standalone script with inline dependencies (PEP 723). Server instructions are included in AGENTS.md when provided. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
610 lines
21 KiB
Python
610 lines
21 KiB
Python
"""Tests for generate CLI command."""
|
|
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
import mcp.types
|
|
|
|
from fastmcp import Client, FastMCP
|
|
from fastmcp.cli.cli import _infer_server_name
|
|
from fastmcp.utilities.generate import (
|
|
generate_agents_md,
|
|
generate_args_dict,
|
|
generate_auth_code,
|
|
generate_tool_script,
|
|
generate_typed_params,
|
|
json_schema_to_python_type,
|
|
to_snake_case,
|
|
)
|
|
|
|
|
|
class TestSnakeCase:
|
|
def test_hyphenated_names(self):
|
|
assert to_snake_case("get-document") == "get_document"
|
|
assert to_snake_case("list-all-items") == "list_all_items"
|
|
|
|
def test_dotted_names(self):
|
|
assert to_snake_case("chrome.getTabs") == "chrome_get_tabs"
|
|
assert to_snake_case("list.items") == "list_items"
|
|
|
|
def test_camel_case(self):
|
|
assert to_snake_case("getDocument") == "get_document"
|
|
assert to_snake_case("listAllItems") == "list_all_items"
|
|
|
|
def test_mixed_formats(self):
|
|
assert to_snake_case("get-documentId") == "get_document_id"
|
|
assert to_snake_case("Chrome.getTabs") == "chrome_get_tabs"
|
|
|
|
|
|
class TestJsonSchemaToType:
|
|
def test_basic_types(self):
|
|
assert json_schema_to_python_type({"type": "string"}) == "str"
|
|
assert json_schema_to_python_type({"type": "integer"}) == "int"
|
|
assert json_schema_to_python_type({"type": "number"}) == "float"
|
|
assert json_schema_to_python_type({"type": "boolean"}) == "bool"
|
|
assert json_schema_to_python_type({"type": "array"}) == "list"
|
|
assert json_schema_to_python_type({"type": "object"}) == "dict"
|
|
|
|
def test_unknown_type(self):
|
|
assert json_schema_to_python_type({"type": "unknown"}) == "Any"
|
|
assert json_schema_to_python_type({}) == "Any"
|
|
|
|
|
|
class TestTypedParams:
|
|
def test_no_parameters(self):
|
|
schema = {"type": "object", "properties": {}}
|
|
params, names = generate_typed_params(schema)
|
|
assert params == ""
|
|
assert names == []
|
|
|
|
def test_required_parameter(self):
|
|
schema = {
|
|
"type": "object",
|
|
"properties": {"name": {"type": "string"}},
|
|
"required": ["name"],
|
|
}
|
|
params, names = generate_typed_params(schema)
|
|
assert params == "name: str"
|
|
assert names == ["name"]
|
|
|
|
def test_optional_parameter(self):
|
|
schema = {
|
|
"type": "object",
|
|
"properties": {"age": {"type": "integer"}},
|
|
}
|
|
params, names = generate_typed_params(schema)
|
|
assert params == "age: int | None = None"
|
|
assert names == ["age"]
|
|
|
|
def test_mixed_parameters(self):
|
|
schema = {
|
|
"type": "object",
|
|
"properties": {
|
|
"name": {"type": "string"},
|
|
"age": {"type": "integer"},
|
|
"email": {"type": "string"},
|
|
},
|
|
"required": ["name", "email"],
|
|
}
|
|
params, names = generate_typed_params(schema)
|
|
assert "name: str" in params
|
|
assert "email: str" in params
|
|
assert "age: int | None = None" in params
|
|
assert names == ["name", "age", "email"]
|
|
|
|
|
|
class TestArgsDict:
|
|
def test_empty_params(self):
|
|
result = generate_args_dict([])
|
|
assert result == "{}"
|
|
|
|
def test_single_param(self):
|
|
result = generate_args_dict(["name"])
|
|
assert '"name": name' in result
|
|
|
|
def test_multiple_params(self):
|
|
result = generate_args_dict(["name", "age", "email"])
|
|
assert '"name": name' in result
|
|
assert '"age": age' in result
|
|
assert '"email": email' in result
|
|
|
|
|
|
class TestAuthCode:
|
|
def test_oauth_mode(self):
|
|
imports, auth_func = generate_auth_code(
|
|
"oauth", None, "https://example.com/mcp"
|
|
)
|
|
assert "from fastmcp.client.auth import OAuth" in imports
|
|
assert 'OAuth(mcp_url="https://example.com/mcp")' in auth_func
|
|
assert "def get_auth():" in auth_func
|
|
|
|
def test_env_var_mode(self):
|
|
imports, auth_func = generate_auth_code(
|
|
"env_var", "MY_API_TOKEN", "https://example.com/mcp"
|
|
)
|
|
assert imports == ""
|
|
assert 'os.environ.get("MY_API_TOKEN")' in auth_func
|
|
assert "Missing required environment variable: MY_API_TOKEN" in auth_func
|
|
assert "def get_auth():" in auth_func
|
|
|
|
def test_token_mode(self):
|
|
imports, auth_func = generate_auth_code(
|
|
"token", "sk-test-123", "https://example.com/mcp"
|
|
)
|
|
assert imports == ""
|
|
assert 'return "sk-test-123"' in auth_func
|
|
assert "def get_auth():" in auth_func
|
|
|
|
def test_none_mode(self):
|
|
imports, auth_func = generate_auth_code("none", None, "https://example.com/mcp")
|
|
assert imports == ""
|
|
assert "return None" in auth_func
|
|
assert "def get_auth():" in auth_func
|
|
|
|
|
|
class TestToolScript:
|
|
def test_simple_tool(self):
|
|
tool = mcp.types.Tool(
|
|
name="echo",
|
|
description="Echo back the input",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {"text": {"type": "string"}},
|
|
"required": ["text"],
|
|
},
|
|
)
|
|
script = generate_tool_script(tool, "https://example.com/mcp")
|
|
|
|
# Check PEP 723 metadata
|
|
assert "# /// script" in script
|
|
assert '# dependencies = ["fastmcp>=2.0.0"]' in script
|
|
|
|
# Check function signature
|
|
assert "async def echo(text: str) -> Any:" in script
|
|
|
|
# Check tool call
|
|
assert 'await client.call_tool("echo"' in script
|
|
|
|
# Check server URL
|
|
assert 'SERVER_URL = "https://example.com/mcp"' in script
|
|
|
|
# Check JSON parameter handling in __main__
|
|
assert "import json" in script
|
|
assert "import sys" in script
|
|
assert "params = json.loads(sys.argv[1]) if len(sys.argv) > 1 else {}" in script
|
|
assert "asyncio.run(echo(**params))" in script
|
|
|
|
def test_tool_with_no_params(self):
|
|
tool = mcp.types.Tool(
|
|
name="get-status",
|
|
description="Get server status",
|
|
inputSchema={"type": "object", "properties": {}},
|
|
)
|
|
script = generate_tool_script(tool, "https://example.com/mcp")
|
|
|
|
# Should have function with no params
|
|
assert "async def get_status() -> Any:" in script
|
|
|
|
def test_tool_with_optional_params(self):
|
|
tool = mcp.types.Tool(
|
|
name="search",
|
|
description="Search for items",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"query": {"type": "string"},
|
|
"limit": {"type": "integer"},
|
|
},
|
|
"required": ["query"],
|
|
},
|
|
)
|
|
script = generate_tool_script(tool, "https://example.com/mcp")
|
|
|
|
# Check mixed params
|
|
assert "query: str" in script
|
|
assert "limit: int | None = None" in script
|
|
|
|
def test_tool_with_oauth_auth(self):
|
|
tool = mcp.types.Tool(
|
|
name="echo",
|
|
description="Echo back",
|
|
inputSchema={"type": "object", "properties": {"text": {"type": "string"}}},
|
|
)
|
|
script = generate_tool_script(tool, "https://example.com/mcp", "oauth", None)
|
|
|
|
# Check OAuth import
|
|
assert "from fastmcp.client.auth import OAuth" in script
|
|
|
|
# Check OAuth instantiation with mcp_url
|
|
assert 'OAuth(mcp_url="https://example.com/mcp")' in script
|
|
|
|
def test_tool_with_env_var_auth(self):
|
|
tool = mcp.types.Tool(
|
|
name="echo",
|
|
description="Echo back",
|
|
inputSchema={"type": "object", "properties": {"text": {"type": "string"}}},
|
|
)
|
|
script = generate_tool_script(
|
|
tool, "https://example.com/mcp", "env_var", "MY_API_TOKEN"
|
|
)
|
|
|
|
# Check env var reading
|
|
assert 'os.environ.get("MY_API_TOKEN")' in script
|
|
|
|
# Check error handling
|
|
assert "Missing required environment variable: MY_API_TOKEN" in script
|
|
|
|
# Should NOT have OAuth import
|
|
assert "from fastmcp.client.auth import OAuth" not in script
|
|
|
|
def test_tool_with_token_auth(self):
|
|
tool = mcp.types.Tool(
|
|
name="echo",
|
|
description="Echo back",
|
|
inputSchema={"type": "object", "properties": {"text": {"type": "string"}}},
|
|
)
|
|
script = generate_tool_script(
|
|
tool, "https://example.com/mcp", "token", "sk-test-123"
|
|
)
|
|
|
|
# Check embedded token
|
|
assert 'return "sk-test-123"' in script
|
|
|
|
# Should NOT have OAuth import
|
|
assert "from fastmcp.client.auth import OAuth" not in script
|
|
|
|
def test_tool_with_no_auth(self):
|
|
tool = mcp.types.Tool(
|
|
name="echo",
|
|
description="Echo back",
|
|
inputSchema={"type": "object", "properties": {"text": {"type": "string"}}},
|
|
)
|
|
script = generate_tool_script(tool, "https://example.com/mcp", "none", None)
|
|
|
|
# Check no auth
|
|
assert "return None" in script
|
|
|
|
# Should NOT have OAuth import
|
|
assert "from fastmcp.client.auth import OAuth" not in script
|
|
|
|
|
|
class TestAgentsMd:
|
|
def test_basic_generation_no_auth(self):
|
|
tools = [
|
|
mcp.types.Tool(
|
|
name="echo",
|
|
description="Echo back",
|
|
inputSchema={"type": "object"},
|
|
),
|
|
mcp.types.Tool(
|
|
name="reverse",
|
|
description="Reverse text",
|
|
inputSchema={"type": "object"},
|
|
),
|
|
]
|
|
|
|
md = generate_agents_md(
|
|
"TestServer", "https://example.com", tools, "none", None, None
|
|
)
|
|
|
|
assert "# MCP Tools: TestServer" in md
|
|
assert "Generated from: https://example.com" in md
|
|
assert "Tools: 2" in md
|
|
assert "- `echo.py` - Echo back" in md
|
|
assert "- `reverse.py` - Reverse text" in md
|
|
# Check JSON parameter documentation
|
|
assert "JSON parameters" in md or "JSON as first argument" in md
|
|
assert "uv run" in md
|
|
# Auth documentation should NOT be present
|
|
assert "## Authentication" not in md
|
|
|
|
def test_no_auth_documentation_regardless_of_mode(self):
|
|
"""Auth documentation should not be included in AGENTS.md regardless of auth mode."""
|
|
tools = [
|
|
mcp.types.Tool(
|
|
name="test", description="Test", inputSchema={"type": "object"}
|
|
),
|
|
]
|
|
|
|
# Test all auth modes - none should generate auth documentation
|
|
for auth_mode, auth_value in [
|
|
("none", None),
|
|
("oauth", None),
|
|
("env_var", "MY_API_TOKEN"),
|
|
("token", "sk-test"),
|
|
]:
|
|
md = generate_agents_md(
|
|
"TestServer", "https://example.com", tools, auth_mode, auth_value, None
|
|
)
|
|
# No auth documentation should be present
|
|
assert "## Authentication" not in md
|
|
# Specific auth-related text should not be present
|
|
if auth_mode == "env_var":
|
|
assert "export MY_API_TOKEN" not in md
|
|
assert "FASTMCP_AUTH_TOKEN" not in md
|
|
|
|
def test_server_instructions_included(self):
|
|
tools = [
|
|
mcp.types.Tool(
|
|
name="test", description="Test", inputSchema={"type": "object"}
|
|
),
|
|
]
|
|
instructions = (
|
|
"Make sure to use these tools responsibly and follow rate limits."
|
|
)
|
|
md = generate_agents_md(
|
|
"TestServer", "https://example.com", tools, "none", None, instructions
|
|
)
|
|
|
|
assert "## Server Instructions" in md
|
|
assert instructions in md
|
|
|
|
def test_no_instructions_section_when_none(self):
|
|
tools = [
|
|
mcp.types.Tool(
|
|
name="test", description="Test", inputSchema={"type": "object"}
|
|
),
|
|
]
|
|
md = generate_agents_md(
|
|
"TestServer", "https://example.com", tools, "none", None, None
|
|
)
|
|
|
|
assert "## Server Instructions" not in md
|
|
|
|
|
|
class TestInferServerName:
|
|
def test_simple_url(self):
|
|
assert _infer_server_name("https://example.com/mcp") == "mcp"
|
|
assert _infer_server_name("https://api.github.com/mcp") == "mcp"
|
|
|
|
def test_url_with_hostname(self):
|
|
# Should remove www and common TLDs
|
|
name = _infer_server_name("https://www.example.com")
|
|
assert "example" in name
|
|
|
|
def test_complex_path(self):
|
|
assert _infer_server_name("https://api.example.com/v1/mcp/tools") == "tools"
|
|
|
|
|
|
class TestEndToEnd:
|
|
async def test_generate_and_run_script(self):
|
|
"""Test generating a script and executing it."""
|
|
# Create a test server
|
|
mcp = FastMCP("TestServer")
|
|
|
|
@mcp.tool
|
|
def greet(name: str) -> str:
|
|
"""Greet someone"""
|
|
return f"Hello, {name}!"
|
|
|
|
# Connect and get tools
|
|
async with Client(mcp) as client:
|
|
tools = await client.list_tools()
|
|
|
|
# Generate script
|
|
tool = tools[0]
|
|
script = generate_tool_script(tool, "test://server")
|
|
|
|
# Write to temp file and verify it's valid Python
|
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
|
|
f.write(script)
|
|
script_path = Path(f.name)
|
|
|
|
try:
|
|
# Compile to check for syntax errors
|
|
compile(script, str(script_path), "exec")
|
|
finally:
|
|
script_path.unlink()
|
|
|
|
async def test_full_generation_flow_with_instructions(self):
|
|
"""Test complete flow: server with instructions -> files on disk."""
|
|
# Create a test server with instructions
|
|
mcp = FastMCP("TestServer")
|
|
mcp.instructions = "Use these tools carefully. Rate limit: 100/min."
|
|
|
|
@mcp.tool
|
|
def echo(text: str) -> str:
|
|
"""Echo back text"""
|
|
return text
|
|
|
|
@mcp.tool
|
|
def reverse(text: str) -> str:
|
|
"""Reverse text"""
|
|
return text[::-1]
|
|
|
|
# Connect and get tools + server info
|
|
async with Client(mcp) as client:
|
|
tools = await client.list_tools()
|
|
instructions = (
|
|
client.initialize_result.instructions
|
|
if client.initialize_result
|
|
else None
|
|
)
|
|
server_name = (
|
|
client.initialize_result.serverInfo.name
|
|
if client.initialize_result and client.initialize_result.serverInfo
|
|
else "test_server"
|
|
)
|
|
|
|
# Generate files in temp directory
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
output_dir = Path(tmpdir) / "test_output"
|
|
output_dir.mkdir()
|
|
|
|
# Generate tool scripts
|
|
for tool in tools:
|
|
script = generate_tool_script(tool, "test://server", "none", None)
|
|
filename = to_snake_case(tool.name) + ".py"
|
|
(output_dir / filename).write_text(script)
|
|
|
|
# Generate AGENTS.md
|
|
agents_md = generate_agents_md(
|
|
server_name, "test://server", tools, "none", None, instructions
|
|
)
|
|
(output_dir / "AGENTS.md").write_text(agents_md)
|
|
|
|
# Verify files exist
|
|
assert (output_dir / "echo.py").exists()
|
|
assert (output_dir / "reverse.py").exists()
|
|
assert (output_dir / "AGENTS.md").exists()
|
|
|
|
# Verify AGENTS.md content
|
|
agents_content = (output_dir / "AGENTS.md").read_text()
|
|
assert "## Server Instructions" in agents_content
|
|
assert "Use these tools carefully" in agents_content
|
|
assert "Rate limit: 100/min" in agents_content
|
|
assert "- `echo.py` - Echo back text" in agents_content
|
|
assert "- `reverse.py` - Reverse text" in agents_content
|
|
assert "## Authentication" not in agents_content # No auth docs
|
|
|
|
# Verify script content
|
|
echo_script = (output_dir / "echo.py").read_text()
|
|
assert "async def echo(text: str)" in echo_script
|
|
assert 'SERVER_URL = "test://server"' in echo_script
|
|
assert "return None" in echo_script # No auth mode
|
|
|
|
async def test_generation_with_different_auth_modes(self):
|
|
"""Test generating scripts with all auth modes."""
|
|
mcp = FastMCP("AuthTestServer")
|
|
|
|
@mcp.tool
|
|
def test_tool() -> str:
|
|
"""Test tool"""
|
|
return "ok"
|
|
|
|
async with Client(mcp) as client:
|
|
tools = await client.list_tools()
|
|
|
|
tool = tools[0]
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
# Test OAuth mode
|
|
script_oauth = generate_tool_script(
|
|
tool, "https://api.test.com", "oauth", None
|
|
)
|
|
oauth_path = Path(tmpdir) / "oauth.py"
|
|
oauth_path.write_text(script_oauth)
|
|
assert "from fastmcp.client.auth import OAuth" in script_oauth
|
|
assert 'OAuth(mcp_url="https://api.test.com")' in script_oauth
|
|
compile(script_oauth, str(oauth_path), "exec") # Verify syntax
|
|
|
|
# Test env var mode
|
|
script_env = generate_tool_script(
|
|
tool, "https://api.test.com", "env_var", "MY_TOKEN"
|
|
)
|
|
env_path = Path(tmpdir) / "env.py"
|
|
env_path.write_text(script_env)
|
|
assert 'os.environ.get("MY_TOKEN")' in script_env
|
|
assert "Missing required environment variable: MY_TOKEN" in script_env
|
|
compile(script_env, str(env_path), "exec") # Verify syntax
|
|
|
|
# Test token mode
|
|
script_token = generate_tool_script(
|
|
tool, "https://api.test.com", "token", "sk-test-123"
|
|
)
|
|
token_path = Path(tmpdir) / "token.py"
|
|
token_path.write_text(script_token)
|
|
assert 'return "sk-test-123"' in script_token
|
|
compile(script_token, str(token_path), "exec") # Verify syntax
|
|
|
|
# Test none mode
|
|
script_none = generate_tool_script(
|
|
tool, "https://api.test.com", "none", None
|
|
)
|
|
none_path = Path(tmpdir) / "none.py"
|
|
none_path.write_text(script_none)
|
|
assert "return None" in script_none
|
|
compile(script_none, str(none_path), "exec") # Verify syntax
|
|
|
|
|
|
class TestCLICommand:
|
|
"""Tests for the CLI command itself."""
|
|
|
|
async def test_auth_parsing_oauth(self):
|
|
"""Test that --auth oauth is recognized."""
|
|
auth_value = "oauth"
|
|
# Simulate parsing logic
|
|
if auth_value == "oauth":
|
|
auth_mode = "oauth"
|
|
elif auth_value.startswith("$"):
|
|
auth_mode = "env_var"
|
|
else:
|
|
auth_mode = "token"
|
|
assert auth_mode == "oauth"
|
|
|
|
async def test_auth_parsing_env_var(self):
|
|
"""Test that --auth $VAR is recognized as env var."""
|
|
import os
|
|
|
|
# Set up an env var
|
|
os.environ["TEST_TOKEN"] = "test-value"
|
|
|
|
auth_value = "$TEST_TOKEN"
|
|
# Simulate parsing logic
|
|
if auth_value == "oauth":
|
|
auth_mode = "oauth"
|
|
elif auth_value.startswith("$"):
|
|
auth_mode = "env_var"
|
|
env_var_name = auth_value[1:]
|
|
token = os.environ.get(env_var_name)
|
|
assert token == "test-value"
|
|
else:
|
|
auth_mode = "token"
|
|
assert auth_mode == "env_var"
|
|
|
|
del os.environ["TEST_TOKEN"]
|
|
|
|
async def test_auth_parsing_literal_token(self):
|
|
"""Test that --auth with literal value is treated as token."""
|
|
auth_value = "sk-test-123"
|
|
# Simulate parsing logic
|
|
if auth_value == "oauth":
|
|
auth_mode = "oauth"
|
|
elif auth_value.startswith("$"):
|
|
auth_mode = "env_var"
|
|
else:
|
|
auth_mode = "token"
|
|
assert auth_mode == "token"
|
|
assert auth_value == "sk-test-123"
|
|
|
|
async def test_cli_missing_env_var_fails(self):
|
|
"""Test that missing env var when using $VAR would fail."""
|
|
import os
|
|
|
|
# Ensure env var doesn't exist
|
|
if "NONEXISTENT_VAR" in os.environ:
|
|
del os.environ["NONEXISTENT_VAR"]
|
|
|
|
# Simulate the CLI check
|
|
auth_value = "$NONEXISTENT_VAR"
|
|
env_var_name = auth_value[1:]
|
|
token = os.environ.get(env_var_name)
|
|
assert token is None # Would cause CLI to exit with error
|
|
|
|
async def test_generated_scripts_are_executable(self):
|
|
"""Test that generated scripts can actually be imported and used."""
|
|
mcp = FastMCP("ExecutableTest")
|
|
|
|
@mcp.tool
|
|
def multiply(a: int, b: int) -> int:
|
|
"""Multiply two numbers"""
|
|
return a * b
|
|
|
|
async with Client(mcp) as client:
|
|
tools = await client.list_tools()
|
|
|
|
tool = tools[0]
|
|
script = generate_tool_script(tool, "test://server", "none", None)
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
script_path = Path(tmpdir) / "multiply.py"
|
|
script_path.write_text(script)
|
|
|
|
# Verify it's valid Python
|
|
compile(script, str(script_path), "exec")
|
|
|
|
# Verify key components are present
|
|
assert "async def multiply(a: int, b: int) -> Any:" in script
|
|
assert 'await client.call_tool("multiply"' in script
|
|
assert '"a": a' in script
|
|
assert '"b": b' in script
|