Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion docs/clients/generate-cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
```

Comment thread
coderabbitai[bot] marked this conversation as resolved.
## 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`.
Expand Down
180 changes: 177 additions & 3 deletions src/fastmcp/cli/generate.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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} <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}"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment on lines +588 to +590
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Tool names containing spaces or shell metacharacters will produce broken invocation examples.

tool.name is interpolated directly into the bash example without quoting. While MCP tool names are typically simple identifiers, the spec doesn't forbid special characters. Consider quoting the tool name in the invocation.

Proposed fix
-    invocation = f"uv run --with fastmcp python {cli_filename} call-tool {tool.name}"
+    invocation = f"uv run --with fastmcp python {cli_filename} call-tool '{tool.name}'"


# 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} |")
Comment on lines +602 to +605
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Escape union type labels in SKILL.md tables

When a parameter schema uses a union type (e.g., {"type": ["string", "null"]}), _schema_type_label returns a string containing " | ". In _tool_skill_section, that type_label is inserted directly into the markdown table while only the description is escaped. The unescaped pipe characters split the table into extra columns, producing malformed SKILL.md output for any tool with union-typed parameters. Escaping type_label the same way as description, or rendering union types without |, would avoid the formatting break.

Useful? React with 👍 / 👎.

Comment thread
coderabbitai[bot] marked this conversation as resolved.

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",
"",
]
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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]")


Expand Down
Loading
Loading