diff --git a/docs/clients/generate-cli.mdx b/docs/clients/generate-cli.mdx index 8336374235..4d05e0515c 100644 --- a/docs/clients/generate-cli.mdx +++ b/docs/clients/generate-cli.mdx @@ -23,7 +23,7 @@ fastmcp generate-cli http://localhost:8000/mcp fastmcp generate-cli server.py my_weather_cli.py ``` -The second positional argument sets the output path. When omitted, it defaults to `cli.py`. If the file already exists, the command refuses to overwrite unless you pass `-f`: +The second positional argument sets the output path. When omitted, it defaults to `cli.py`. If either the CLI file or its companion `SKILL.md` already exists, the command refuses to overwrite unless you pass `-f`: ```bash fastmcp generate-cli weather -f @@ -85,6 +85,42 @@ Options: Tool names are preserved exactly as the server defines them — underscores stay as underscores, so `call-tool get_forecast` matches what the server expects. +## Agent Skill + +Alongside the CLI script, `generate-cli` also writes a `SKILL.md` file — a [Claude Code agent skill](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/skills) that documents the generated CLI. The skill includes every tool's exact invocation syntax, parameter flags with types and descriptions, and the utility commands, so an agent can use the CLI immediately without running `--help` or experimenting with flag names. + +The skill is written to the same directory as the CLI script. For a weather server, it looks something like: + +````markdown +--- +name: "weather-cli" +description: "CLI for the weather MCP server. Call tools, list resources, and get prompts." +--- + +# weather CLI + +## Tool Commands + +### get_forecast + +Get the weather forecast for a city. + +```bash +uv run --with fastmcp python cli.py call-tool get_forecast --city --days +``` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--city` | string | yes | City name | +| `--days` | integer | no | Number of forecast days | +```` + +To skip skill generation, pass `--no-skill`: + +```bash +fastmcp generate-cli weather --no-skill +``` + ## How It Works The generated script is a client, not a server. It doesn't bundle or embed the MCP server — it connects to it on every invocation. For URL-based servers, the server needs to be running. For stdio-based servers, the command specified in `CLIENT_SPEC` must be available on the system's `PATH`. diff --git a/src/fastmcp/cli/generate.py b/src/fastmcp/cli/generate.py index 7fdc6cd8a1..b5e6529091 100644 --- a/src/fastmcp/cli/generate.py +++ b/src/fastmcp/cli/generate.py @@ -1,4 +1,4 @@ -"""Generate a standalone CLI script from an MCP server's capabilities.""" +"""Generate a standalone CLI script and agent skill from an MCP server.""" import keyword import re @@ -518,6 +518,152 @@ async def get_prompt( return "\n".join(lines) +# --------------------------------------------------------------------------- +# Skill (SKILL.md) generation +# --------------------------------------------------------------------------- + +_JSON_SCHEMA_TYPE_LABELS: dict[str, str] = { + "string": "string", + "integer": "integer", + "number": "number", + "boolean": "boolean", + "null": "null", + "array": "array", + "object": "object", +} + + +def _param_to_cli_flag(prop_name: str) -> str: + """Convert a JSON Schema property name to its CLI flag form. + + Replicates cyclopts' default_name_transform: camelCase → snake_case, + lowercase, underscores → hyphens, strip leading/trailing hyphens. + """ + safe = _to_python_identifier(prop_name) + # camelCase / PascalCase → snake_case + safe = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", safe) + safe = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", safe) + safe = safe.lower().replace("_", "-").strip("-") + return f"--{safe}" if safe else "--arg" + + +def _schema_type_label(prop_schema: dict[str, Any]) -> str: + """Return a human-readable type label for a property schema.""" + schema_type = prop_schema.get("type", "string") + if isinstance(schema_type, list): + labels = [_JSON_SCHEMA_TYPE_LABELS.get(t, t) for t in schema_type] + return " | ".join(labels) + + label = _JSON_SCHEMA_TYPE_LABELS.get(schema_type, schema_type) + + # For arrays, include item type if simple + if schema_type == "array": + items = prop_schema.get("items", {}) + item_type = items.get("type", "") + if isinstance(item_type, str) and item_type in _JSON_SCHEMA_TYPE_LABELS: + return f"array[{item_type}]" + + return label + + +def _tool_skill_section(tool: mcp.types.Tool, cli_filename: str) -> str: + """Generate a SKILL.md section for a single tool.""" + schema = tool.inputSchema + properties: dict[str, Any] = schema.get("properties", {}) + required = set(schema.get("required", [])) + + # Build example invocation flags + flag_parts_list: list[str] = [] + for p, p_schema in properties.items(): + flag = _param_to_cli_flag(p) + schema_type = p_schema.get("type") + is_bool = schema_type == "boolean" or ( + isinstance(schema_type, list) and "boolean" in schema_type + ) + if is_bool: + flag_parts_list.append(flag) + else: + flag_parts_list.append(f"{flag} ") + flag_parts = " ".join(flag_parts_list) + invocation = f"uv run --with fastmcp python {cli_filename} call-tool {tool.name}" + if flag_parts: + invocation += f" {flag_parts}" + + # Build parameter table rows + rows: list[str] = [] + for prop_name, prop_schema in properties.items(): + flag = f"`{_param_to_cli_flag(prop_name)}`" + type_label = _schema_type_label(prop_schema).replace("|", "\\|") + is_required = "yes" if prop_name in required else "no" + description = prop_schema.get("description", "") + _, needs_json = _schema_to_python_type(prop_schema) + if needs_json: + description = ( + f"{description} (JSON string)" if description else "JSON string" + ) + description = description.replace("\n", " ").replace("|", "\\|") + rows.append(f"| {flag} | {type_label} | {is_required} | {description} |") + + param_table = "" + if rows: + header = "| Flag | Type | Required | Description |\n|------|------|----------|-------------|" + param_table = f"\n{header}\n" + "\n".join(rows) + "\n" + + lines: list[str] = [f"### {tool.name}"] + if tool.description: + lines.extend(["", tool.description]) + lines.extend(["", "```bash", invocation, "```"]) + if param_table: + lines.extend(["", param_table.strip("\n")]) + return "\n".join(lines) + + +def generate_skill_content( + server_name: str, + cli_filename: str, + tools: list[mcp.types.Tool], +) -> str: + """Generate a SKILL.md file for a generated CLI script.""" + skill_name = ( + server_name.replace(" ", "-").lower().replace("\\", "").replace('"', "") + ) + safe_name = server_name.replace("\\", "").replace('"', "") + description = f"CLI for the {safe_name} MCP server. Call tools, list resources, and get prompts." + + lines = [ + "---", + f'name: "{skill_name}-cli"', + f'description: "{description}"', + "---", + "", + f"# {server_name} CLI", + "", + ] + + if tools: + tool_bodies = "\n\n".join( + _tool_skill_section(tool, cli_filename) for tool in tools + ) + lines.extend(["## Tool Commands", "", tool_bodies, ""]) + + lines.extend( + [ + "## Utility Commands", + "", + "```bash", + f"uv run --with fastmcp python {cli_filename} list-tools", + f"uv run --with fastmcp python {cli_filename} list-resources", + f"uv run --with fastmcp python {cli_filename} read-resource ", + f"uv run --with fastmcp python {cli_filename} list-prompts", + f"uv run --with fastmcp python {cli_filename} get-prompt [key=value ...]", + "```", + "", + ] + ) + + return "\n".join(lines) + + # --------------------------------------------------------------------------- # CLI command # --------------------------------------------------------------------------- @@ -555,22 +701,40 @@ async def generate_cli_command( help="Auth method: 'oauth', a bearer token string, or 'none' to disable", ), ] = None, + no_skill: Annotated[ + bool, + cyclopts.Parameter( + "--no-skill", + help="Skip generating a SKILL.md agent skill alongside the CLI", + ), + ] = False, ) -> None: """Generate a standalone CLI script from an MCP server. Connects to the server, reads its tools/resources/prompts, and writes - a Python script that can invoke them directly. + a Python script that can invoke them directly. Also generates a SKILL.md + agent skill file unless --no-skill is passed. Examples: fastmcp generate-cli weather fastmcp generate-cli weather my_cli.py fastmcp generate-cli http://localhost:8000/mcp fastmcp generate-cli server.py output.py -f + fastmcp generate-cli weather --no-skill """ output_path = Path(output) + skill_path = output_path.parent / "SKILL.md" + + # Check both files up front before doing any work + existing: list[Path] = [] if output_path.exists() and not force: + existing.append(output_path) + if not no_skill and skill_path.exists() and not force: + existing.append(skill_path) + if existing: + names = ", ".join(f"[cyan]{p}[/cyan]" for p in existing) console.print( - f"[bold red]Error:[/bold red] [cyan]{output_path}[/cyan] already exists. " + f"[bold red]Error:[/bold red] {names} already exist(s). " f"Use [cyan]-f[/cyan] to overwrite." ) sys.exit(1) @@ -612,6 +776,16 @@ async def generate_cli_command( f"[green]✓[/green] Wrote [cyan]{output_path}[/cyan] " f"with {len(tools)} tool command(s)" ) + + if not no_skill: + skill_content = generate_skill_content( + server_name=server_name, + cli_filename=output_path.name, + tools=tools, + ) + skill_path.write_text(skill_content) + console.print(f"[green]✓[/green] Wrote [cyan]{skill_path}[/cyan]") + console.print(f"[dim]Run: python {output_path} --help[/dim]") diff --git a/tests/cli/test_generate_cli.py b/tests/cli/test_generate_cli.py index f513338d75..8f567c846a 100644 --- a/tests/cli/test_generate_cli.py +++ b/tests/cli/test_generate_cli.py @@ -13,11 +13,14 @@ from fastmcp.cli.client import Client from fastmcp.cli.generate import ( _derive_server_name, + _param_to_cli_flag, _schema_to_python_type, + _schema_type_label, _to_python_identifier, _tool_function_source, generate_cli_command, generate_cli_script, + generate_skill_content, serialize_transport, ) from fastmcp.client.transports.stdio import StdioTransport @@ -636,3 +639,280 @@ async def test_file_is_executable(self, tmp_path: Path): output = tmp_path / "cli.py" await generate_cli_command("test-server", str(output)) assert output.stat().st_mode & 0o111 + + @pytest.mark.usefixtures("_patch_client") + async def test_writes_skill_file(self, tmp_path: Path): + output = tmp_path / "cli.py" + await generate_cli_command("test-server", str(output)) + skill_path = tmp_path / "SKILL.md" + assert skill_path.exists() + content = skill_path.read_text() + assert "---" in content + assert "name:" in content + + @pytest.mark.usefixtures("_patch_client") + async def test_skill_contains_tools(self, tmp_path: Path): + output = tmp_path / "cli.py" + await generate_cli_command("test-server", str(output)) + content = (tmp_path / "SKILL.md").read_text() + assert "### greet" in content + assert "### add" in content + assert "--name" in content + assert "call-tool greet" in content + + @pytest.mark.usefixtures("_patch_client") + async def test_no_skill_flag(self, tmp_path: Path): + output = tmp_path / "cli.py" + await generate_cli_command("test-server", str(output), no_skill=True) + assert not (tmp_path / "SKILL.md").exists() + + @pytest.mark.usefixtures("_patch_client") + async def test_error_if_skill_exists(self, tmp_path: Path): + output = tmp_path / "cli.py" + (tmp_path / "SKILL.md").write_text("existing") + with pytest.raises(SystemExit): + await generate_cli_command("test-server", str(output)) + + @pytest.mark.usefixtures("_patch_client") + async def test_force_overwrites_skill(self, tmp_path: Path): + output = tmp_path / "cli.py" + (tmp_path / "SKILL.md").write_text("existing") + await generate_cli_command("test-server", str(output), force=True) + content = (tmp_path / "SKILL.md").read_text() + assert content != "existing" + assert "### greet" in content + + @pytest.mark.usefixtures("_patch_client") + async def test_skill_references_cli_filename(self, tmp_path: Path): + output = tmp_path / "my_weather.py" + await generate_cli_command("test-server", str(output)) + content = (tmp_path / "SKILL.md").read_text() + assert "uv run --with fastmcp python my_weather.py" in content + + +# --------------------------------------------------------------------------- +# _param_to_cli_flag +# --------------------------------------------------------------------------- + + +class TestParamToCliFlag: + def test_simple_name(self): + assert _param_to_cli_flag("city") == "--city" + + def test_underscore_name(self): + assert _param_to_cli_flag("max_days") == "--max-days" + + def test_hyphenated_name(self): + # content-type → _to_python_identifier → content_type → --content-type + assert _param_to_cli_flag("content-type") == "--content-type" + + def test_digit_prefix(self): + # 3d_mode → _3d_mode → --3d-mode (leading underscore stripped) + assert _param_to_cli_flag("3d_mode") == "--3d-mode" + + def test_trailing_underscore(self): + # from → from_ after identifier sanitization; Cyclopts strips trailing "-" + assert _param_to_cli_flag("from") == "--from" + + def test_camel_case(self): + # camelCase → camel-case (cyclopts default_name_transform) + assert _param_to_cli_flag("myParam") == "--my-param" + + def test_pascal_case(self): + assert _param_to_cli_flag("MyParam") == "--my-param" + + +# --------------------------------------------------------------------------- +# _schema_type_label +# --------------------------------------------------------------------------- + + +class TestSchemaTypeLabel: + def test_simple_string(self): + assert _schema_type_label({"type": "string"}) == "string" + + def test_integer(self): + assert _schema_type_label({"type": "integer"}) == "integer" + + def test_array_of_strings(self): + assert ( + _schema_type_label({"type": "array", "items": {"type": "string"}}) + == "array[string]" + ) + + def test_union_types(self): + result = _schema_type_label({"type": ["string", "null"]}) + assert "string" in result + assert "null" in result + + def test_object(self): + assert _schema_type_label({"type": "object"}) == "object" + + def test_missing_type(self): + assert _schema_type_label({}) == "string" + + +# --------------------------------------------------------------------------- +# generate_skill_content +# --------------------------------------------------------------------------- + + +class TestGenerateSkillContent: + def test_frontmatter(self): + content = generate_skill_content("weather", "cli.py", []) + assert content.startswith("---\n") + assert 'name: "weather-cli"' in content + assert "description:" in content + + def test_no_tools(self): + content = generate_skill_content("weather", "cli.py", []) + assert "## Utility Commands" in content + assert "## Tool Commands" not in content + + def test_tool_sections(self): + tools = [ + mcp.types.Tool( + name="greet", + description="Say hello", + inputSchema={ + "type": "object", + "properties": { + "name": {"type": "string", "description": "Who to greet"} + }, + "required": ["name"], + }, + ), + ] + content = generate_skill_content("test", "cli.py", tools) + assert "## Tool Commands" in content + assert "### greet" in content + assert "Say hello" in content + assert "call-tool greet" in content + assert "`--name`" in content + assert "| string |" in content + assert "| yes |" in content + + def test_frontmatter_with_tools_starts_at_column_zero(self): + tools = [ + mcp.types.Tool( + name="greet", + inputSchema={"type": "object", "properties": {}}, + ), + ] + content = generate_skill_content("weather", "cli.py", tools) + assert content.splitlines()[0] == "---" + + def test_optional_param(self): + tools = [ + mcp.types.Tool( + name="search", + description="Search things", + inputSchema={ + "type": "object", + "properties": { + "query": {"type": "string"}, + "limit": {"type": "integer"}, + }, + "required": ["query"], + }, + ), + ] + content = generate_skill_content("test", "cli.py", tools) + # query is required, limit is not + assert "| `--query` | string | yes |" in content + assert "| `--limit` | integer | no |" in content + + def test_complex_json_param(self): + tools = [ + mcp.types.Tool( + name="create", + description="Create item", + inputSchema={ + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": {"x": {"type": "integer"}}, + }, + }, + "required": ["data"], + }, + ), + ] + content = generate_skill_content("test", "cli.py", tools) + assert "JSON string" in content + + def test_no_params_tool(self): + tools = [ + mcp.types.Tool( + name="ping", + description="Ping the server", + inputSchema={"type": "object", "properties": {}}, + ), + ] + content = generate_skill_content("test", "cli.py", tools) + assert "### ping" in content + assert "call-tool ping" in content + # No parameter table + assert "| Flag |" not in content + + def test_cli_filename_in_utility_commands(self): + content = generate_skill_content("test", "my_cli.py", []) + assert "uv run --with fastmcp python my_cli.py list-tools" in content + assert "uv run --with fastmcp python my_cli.py list-resources" in content + + def test_pipe_in_description_escaped(self): + tools = [ + mcp.types.Tool( + name="test", + description="Test", + inputSchema={ + "type": "object", + "properties": { + "mode": {"type": "string", "description": "a|b|c"}, + }, + }, + ), + ] + content = generate_skill_content("test", "cli.py", tools) + assert "a\\|b\\|c" in content + + def test_union_type_pipes_escaped(self): + tools = [ + mcp.types.Tool( + name="test", + description="Test", + inputSchema={ + "type": "object", + "properties": { + "val": {"type": ["string", "null"]}, + }, + }, + ), + ] + content = generate_skill_content("test", "cli.py", tools) + # Pipes in type label must be escaped so markdown table renders correctly + assert "string \\| null" in content + + def test_boolean_param_no_value_placeholder(self): + tools = [ + mcp.types.Tool( + name="run", + description="Run something", + inputSchema={ + "type": "object", + "properties": { + "verbose": {"type": "boolean", "description": "Verbose output"}, + "name": {"type": "string"}, + }, + }, + ), + ] + content = generate_skill_content("test", "cli.py", tools) + assert "--verbose " not in content + assert "--name " in content + + def test_server_name_in_header(self): + content = generate_skill_content("My Weather API", "cli.py", []) + assert "# My Weather API CLI" in content + assert 'name: "my-weather-api-cli"' in content