fastmcp/tests/cli/test_generate.py
jake@prefect.io fb28f9cda9 Add fastmcp generate command for creating standalone tool scripts
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>
2025-11-05 17:33:39 -05:00

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