generate-cli: auto-generate SKILL.md agent skill (#3115)

* generate-cli: auto-generate SKILL.md alongside CLI script

generate-cli now produces a SKILL.md agent skill file next to the CLI
script, documenting every tool's exact invocation syntax, parameter
flags, and types. Agents can use the CLI immediately without discovery.

* Use uv run --with fastmcp in generated SKILL.md invocations

* Fix skill generation issues from review

- Escape pipe chars in union type labels so markdown tables render
- Boolean params omit <value> placeholder in example invocations
- Quote YAML frontmatter values to handle special chars in names
- Match cyclopts camelCase→snake_case in flag derivation
- Use four-backtick fence for nested code block in docs

* Replace --skill/--no-skill with just --no-skill

* Escape quotes in YAML frontmatter description

* Strip newlines from param descriptions in skill table rows

* Detect boolean union types for flag placeholder
This commit is contained in:
Jeremiah Lowin 2026-02-08 17:09:28 -05:00 committed by GitHub
commit f7cdd20a42
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 494 additions and 4 deletions

View file

@ -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 <value> --days <value>
```
| 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`.

View file

@ -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 @@ def generate_cli_script(
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} <value>")
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 <uri>",
f"uv run --with fastmcp python {cli_filename} list-prompts",
f"uv run --with fastmcp python {cli_filename} get-prompt <name> [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]")

View file

@ -13,11 +13,14 @@ from fastmcp.cli import generate as generate_module
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 @@ class TestGenerateCliCommand:
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 <value>" not in content
assert "--name <value>" 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