Skip to content

Add fastmcp generate-cli command#3065

Merged
jlowin merged 11 commits intomainfrom
feat/generate-cli
Feb 4, 2026
Merged

Add fastmcp generate-cli command#3065
jlowin merged 11 commits intomainfrom
feat/generate-cli

Conversation

@jlowin
Copy link
Copy Markdown
Member

@jlowin jlowin commented Feb 3, 2026

Any MCP server — FastMCP or otherwise — already describes its capabilities through tool schemas. generate-cli takes that schema and turns it into a real CLI you can run, edit, and distribute.

fastmcp generate-cli weather
fastmcp generate-cli server.py my_cli.py
fastmcp generate-cli http://localhost:8000/mcp -f

The generated script embeds the resolved transport (URL or stdio command) and has proper typed subcommands:

python cli.py call-tool get_forecast --city London --days 3
python cli.py call-tool get_forecast --help    # shows typed parameters
python cli.py list-tools                       # enumerate capabilities
python cli.py list-resources
python cli.py read-resource docs://readme
python cli.py list-prompts
python cli.py get-prompt analyze data='[1,2,3]'

Tool parameters are mapped from JSON Schema to Python type annotations with cyclopts.Parameter metadata, so you get help text, type coercion, and required/optional validation for free. Tool names are preserved as-is (underscores, not kebab-cased).

Connects to any MCP server, reads its tool/resource/prompt schemas,
and writes a standalone Python CLI script with typed subcommands.
@jlowin jlowin added the feature Major new functionality. Reserved for 2-4 significant PRs per release. Not for issues. label Feb 3, 2026
@marvin-context-protocol marvin-context-protocol Bot added the cli Related to FastMCP CLI commands (run, dev, install) or CLI functionality. label Feb 3, 2026
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2c705faa87

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/fastmcp/cli/generate.py Outdated
Comment on lines +104 to +108
if is_required:
annotation = (
f'Annotated[{py_type}, cyclopts.Parameter(help="{help_escaped}")]'
)
param_lines.append(f" {prop_name}: {annotation},")
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 Sanitize tool parameter names before emitting Python args

The generator emits JSON Schema property names directly as Python keyword parameters. MCP/JSON Schema property names are arbitrary strings, so names containing hyphens, spaces, or Python keywords (e.g., from) will produce invalid syntax in the generated script and make compile() fail. This is triggered whenever a server exposes such property names; consider mapping to safe identifiers (and translating back when building the argument dict) or using **kwargs handling.

Useful? React with 👍 / 👎.

Comment thread src/fastmcp/cli/generate.py Outdated
Comment on lines +122 to +123
# Function name: use tool name directly (preserve underscores)
fn_name = tool.name.replace("-", "_")
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 Sanitize tool names beyond hyphens for function names

Tool names are only normalized by replacing - with _ when generating function names. MCP tool names can include other non-identifier characters (e.g., ., /, spaces) or start with digits, which will produce invalid Python function definitions and break the generated CLI. This will surface for any server that uses such tool names; consider a fuller identifier sanitization step while still preserving the original name in the command decorator.

Useful? React with 👍 / 👎.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 3, 2026

Walkthrough

Adds a top-level CLI command generate-cli and a new module that generates a standalone Python CLI script from an MCP server. The generator resolves a server spec (URL, file, or configured server), serializes transport configuration, connects to discover tools, maps JSON Schema to Python types and cyclopts flags, emits per-tool subcommands plus utility commands (list-tools, list-resources, read-resource, list-prompts, get-prompt), writes the assembled runnable script to an output file (optional --force overwrite), makes it executable, and reports connection or discovery errors via exit status.

Possibly related PRs

  • jlowin/fastmcp PR 3054: Modifies src/fastmcp/cli/cli.py by adding imports and registering new top-level CLI commands, touching the same CLI entrypoint where generate-cli is registered.
🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Description check ⚠️ Warning The description provides good detail on the feature and usage examples, but the PR lacks self-review and testing checkboxes as required by the repository's template. Complete the Contributors and Review checklists by checking the required boxes and confirming testing, documentation updates, and self-review were performed.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: adding a new CLI command called generate-cli to the fastmcp tool.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/generate-cli

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (1)
src/fastmcp/cli/generate.py (1)

494-495: Edge case: trailing colon yields empty name.

If server_spec is "source:", the split returns an empty string, which could cause issues in generated code (e.g., empty app name).

♻️ Proposed fix to handle edge case
     # Bare name or qualified name
     if ":" in server_spec:
-        return server_spec.split(":", 1)[1]
+        name = server_spec.split(":", 1)[1]
+        return name if name else server_spec.split(":", 1)[0]

     return server_spec

Comment thread src/fastmcp/cli/generate.py Outdated
Comment thread src/fastmcp/cli/generate.py
Comment thread src/fastmcp/cli/generate.py Outdated
@marvin-context-protocol
Copy link
Copy Markdown
Contributor

Test Failure Analysis

Summary: The test_file_is_executable test fails on Windows because Unix-style executable permissions don't exist on Windows.

Root Cause: The test at tests/cli/test_generate_cli.py:403 checks for Unix executable bits (st_mode & 0o111) which are not supported on Windows. While the chmod call at src/fastmcp/cli/generate.py:471 doesn't error on Windows, it has no effect—Windows determines executability by file extension, not permission bits.

Suggested Solution: Skip this test on Windows, as the executable bit concept doesn't apply there. Python files are executable on Windows by default when they have the proper shebang and file association.

Add the following decorator to the test:

@pytest.mark.skipif(
    sys.platform == "win32",
    reason="Executable permissions don't exist on Windows"
)
async def test_file_is_executable(self, tmp_path: Path):

Don't forget to add import sys at the top of the test file if it's not already there.

Detailed Analysis

The failure occurs in the Windows test job (Python 3.10 on windows-latest):

tests\cli\test_generate_cli.py:403: AssertionError
>       assert output.stat().st_mode & 0o111
E       AssertionError: assert (33206 & 73)

The st_mode value of 33206 (0o100666 in octal) on Windows represents a regular file without executable bits, because Windows doesn't use Unix permission bits. The test expects at least one executable bit to be set (owner, group, or other), but Windows doesn't support this concept.

The chmod operation at line 471 silently succeeds on Windows but has no effect. Windows determines whether a file is executable based on its file extension (e.g., .exe, .bat, .py) and file associations, not permission bits.

Related Files
  • tests/cli/test_generate_cli.py:400-403 - The failing test
  • src/fastmcp/cli/generate.py:471 - The chmod operation that's Windows-incompatible
  • Other tests in the codebase use pytest.mark.skipif for Windows-specific skips (e.g., tests/test_mcp_config.py:43, tests/client/test_streamable_http.py:242)

- Sanitize tool and parameter names to valid Python identifiers
- Replace bare except Exception with specific exception types
- Escape server name in generated string literals
- Handle trailing colon edge case in _derive_server_name
- Clarify in docs that generated CLI is a client, not a bundled server
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Comment thread src/fastmcp/cli/generate.py Outdated
Comment thread src/fastmcp/cli/generate.py Outdated
- Use single-quoted docstrings to avoid triple-quote escaping issues
- Escape quotes in app_name derived from server_name
- Add tests for descriptions with quotes and server names with quotes

Addresses CodeRabbit review comments about insufficient escaping.
- Simple types (str, int, float, bool): Direct typed flags
- Arrays of simple types (list[str], list[int]): Repeatable flags via cyclopts
- Complex types (objects, nested arrays): Accept JSON strings with parsing
- JSON schema shown in help text for complex parameters
- Proper escaping of newlines and quotes in help text
- Filter out None and empty list defaults when calling tools

This gives typed, discoverable CLIs for common cases while handling
complex schemas via JSON input.
- Document simple types as direct typed flags
- Document arrays of simple types as repeatable flags
- Document complex types as JSON strings with schema in help
- Add examples showing all three patterns
High priority fixes:
- Complex type defaults: Serialize dict/list defaults to JSON strings
- List params: Preserve help metadata with Annotated wrapper
- Name collisions: Detect and error on sanitized name conflicts
- JSON parsing: Use isinstance check for safety with defaults

Added tests for:
- Complex types with default values
- Parameter name collision detection
- Updated existing tests to match new format
- Generator now uses pydantic_core.to_json() instead of json.dumps()
- Consistent with rest of fastmcp codebase
- Generated CLI still uses plain json module (standalone script)
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Comment thread src/fastmcp/cli/generate.py Outdated
Comment on lines +116 to +141
def serialize_transport(
resolved: str | dict[str, Any] | ClientTransport,
) -> tuple[str, set[str]]:
"""Serialize a resolved transport to a Python expression string.

Returns ``(expression, extra_imports)`` where *extra_imports* is a set of
import lines needed by the expression.
"""
if isinstance(resolved, str):
return repr(resolved), set()

if isinstance(resolved, StdioTransport):
parts = [f"command={resolved.command!r}", f"args={resolved.args!r}"]
if resolved.env:
parts.append(f"env={resolved.env!r}")
if resolved.cwd:
parts.append(f"cwd={resolved.cwd!r}")
expr = f"StdioTransport({', '.join(parts)})"
imports = {"from fastmcp.client.transports import StdioTransport"}
return expr, imports

if isinstance(resolved, dict):
return repr(resolved), set()

# Fallback: try repr
return repr(resolved), set()
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 | 🟠 Major

Preserve non-default StdioTransport fields during serialization.

log_file (notably for .py specs) and keep_alive are currently dropped, and empty env/cwd are ignored via truthiness checks. This can change runtime behavior in the generated CLI.

🛠️ Proposed fix
     if isinstance(resolved, StdioTransport):
-        parts = [f"command={resolved.command!r}", f"args={resolved.args!r}"]
-        if resolved.env:
-            parts.append(f"env={resolved.env!r}")
-        if resolved.cwd:
-            parts.append(f"cwd={resolved.cwd!r}")
+        imports = {"from fastmcp.client.transports import StdioTransport"}
+        parts = [f"command={resolved.command!r}", f"args={resolved.args!r}"]
+        if resolved.env is not None:
+            parts.append(f"env={resolved.env!r}")
+        if resolved.cwd is not None:
+            parts.append(f"cwd={resolved.cwd!r}")
+        if resolved.keep_alive is not True:
+            parts.append(f"keep_alive={resolved.keep_alive!r}")
+        if resolved.log_file is not None:
+            if isinstance(resolved.log_file, Path):
+                parts.append(f"log_file=Path({str(resolved.log_file)!r})")
+                imports.add("from pathlib import Path")
+            else:
+                parts.append(f"log_file={resolved.log_file!r}")
         expr = f"StdioTransport({', '.join(parts)})"
-        imports = {"from fastmcp.client.transports import StdioTransport"}
         return expr, imports

Comment thread src/fastmcp/cli/generate.py Outdated
@jlowin jlowin merged commit 4262cfc into main Feb 4, 2026
13 checks passed
@jlowin jlowin deleted the feat/generate-cli branch February 4, 2026 02:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cli Related to FastMCP CLI commands (run, dev, install) or CLI functionality. feature Major new functionality. Reserved for 2-4 significant PRs per release. Not for issues.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant