Skip to content
Open
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
5 changes: 3 additions & 2 deletions src/fastmcp/cli/install/claude_code.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Claude Code integration for FastMCP install using Cyclopts."""

import shlex
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Remove unnecessary and incorrect shlex.quote() usage.

The use of shlex.quote() is incorrect here. Since subprocess.run() is called with a list argument (line 142) and shell=True is not set, Python passes each list element directly to the executable without shell interpretation. In this context:

  • Special characters in the name are already safe—no shell parsing occurs
  • shlex.quote() adds literal quote characters that become part of the argument value
  • The Claude CLI would receive a name like 'my-server' (with quotes) instead of my-server

The shlex.quote() function is only needed when constructing command strings that will be interpreted by a shell.

Apply this diff to remove the incorrect escaping:

-import shlex
 import shutil
-    # Build claude mcp add command (escape name to prevent shell injection)
-    cmd_parts = [claude_cmd, "mcp", "add", shlex.quote(name)]
+    # Build claude mcp add command
+    cmd_parts = [claude_cmd, "mcp", "add", name]

Also applies to: 128-129

🤖 Prompt for AI Agents
In src/fastmcp/cli/install/claude_code.py around lines 3, 128-129 (and the
invocation at ~142), remove the unnecessary use of shlex.quote(): delete the
import "import shlex" at line 3 and replace any shlex.quote(name) (and similar
calls) with the raw variable (e.g., name) so the argument list passed to
subprocess.run remains unmodified; ensure no shell=True usage relies on quoting
and run subprocess with the list of plain arguments.

import shutil
import subprocess
import sys
Expand Down Expand Up @@ -124,8 +125,8 @@ def install_claude_code(
# Build the full command
full_command = env_config.build_command(["fastmcp", "run", server_spec])

# Build claude mcp add command
cmd_parts = [claude_cmd, "mcp", "add", name]
# Build claude mcp add command (escape name to prevent shell injection)
cmd_parts = [claude_cmd, "mcp", "add", shlex.quote(name)]

# Add environment variables if specified
if env_vars:
Expand Down
5 changes: 3 additions & 2 deletions src/fastmcp/cli/install/gemini_cli.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Gemini CLI integration for FastMCP install using Cyclopts."""

import shlex
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Remove unnecessary and incorrect shlex.quote() usage.

The use of shlex.quote() is incorrect here for the same reason as in claude_code.py. Since subprocess.run() is called with a list argument (line 139) without shell=True, no shell interpretation occurs. The shlex.quote() will add literal quote characters that become part of the server name, potentially breaking the Gemini CLI command.

Apply this diff to remove the incorrect escaping:

-import shlex
 import shutil
-    # Add server name and command (escape name to prevent shell injection)
-    cmd_parts.extend([shlex.quote(name), full_command[0], "--"])
+    # Add server name and command
+    cmd_parts.extend([name, full_command[0], "--"])

Also applies to: 133-134

🤖 Prompt for AI Agents
In src/fastmcp/cli/install/gemini_cli.py around lines 3 and 133-134, remove the
incorrect use of shlex.quote() that is wrapping server/name arguments (and
remove the now-unused import at line 3); because subprocess.run(...) is invoked
with a list (no shell=True), the server name should be passed as the raw string
elements in the argument list rather than quoted, so replace calls like
shlex.quote(server) with server (or the original unescaped variable) and delete
the shlex import.

import shutil
import subprocess
import sys
Expand Down Expand Up @@ -129,8 +130,8 @@ def install_gemini_cli(
for key, value in env_vars.items():
cmd_parts.extend(["-e", f"{key}={value}"])

# Add server name and command
cmd_parts.extend([name, full_command[0], "--"])
# Add server name and command (escape name to prevent shell injection)
cmd_parts.extend([shlex.quote(name), full_command[0], "--"])
cmd_parts.extend(full_command[1:])

try:
Expand Down
90 changes: 90 additions & 0 deletions tests/cli/test_install.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import shlex
from pathlib import Path
from unittest.mock import MagicMock, patch

from fastmcp.cli.install import install_app

Expand Down Expand Up @@ -311,3 +313,91 @@ def test_project_option(self):
command, bound, _ = install_app.parse_args(cmd_args)
assert command is not None
assert str(bound.arguments["project"]) == str(Path("/path/to/project"))


class TestShellInjectionProtection:
"""Test that install commands properly escape server names to prevent shell injection."""

@patch("fastmcp.cli.install.gemini_cli.find_gemini_command")
@patch("fastmcp.cli.install.gemini_cli.subprocess.run")
def test_gemini_cli_escapes_shell_metacharacters(
self, mock_subprocess, mock_find_gemini
):
"""Test that gemini-cli install properly escapes server names with shell metacharacters."""
from fastmcp.cli.install.gemini_cli import install_gemini_cli

# Mock gemini command as available
mock_find_gemini.return_value = "/usr/bin/gemini"
mock_subprocess.return_value = MagicMock(returncode=0)

# Test with various dangerous characters
dangerous_names = ["test&calc", "test|whoami", "test;ls", "test$USER"]

for dangerous_name in dangerous_names:
mock_subprocess.reset_mock()

# Call install function
install_gemini_cli(
file=Path("/tmp/server.py"),
server_object=None,
name=dangerous_name,
)

# Verify subprocess was called
assert mock_subprocess.called
cmd_parts = mock_subprocess.call_args[0][0]

# Verify the name was shell-quoted
# shlex.quote() wraps strings with special chars in single quotes
expected_quoted = shlex.quote(dangerous_name)
assert expected_quoted in cmd_parts, (
f"Expected {expected_quoted} in command but got {cmd_parts}"
)

# Verify the original unquoted name is NOT in the command
# (unless it happens to be the same as the quoted version)
if expected_quoted != dangerous_name:
assert dangerous_name not in cmd_parts, (
f"Unquoted name {dangerous_name} should not appear in command"
)

@patch("fastmcp.cli.install.claude_code.find_claude_command")
@patch("fastmcp.cli.install.claude_code.subprocess.run")
def test_claude_code_escapes_shell_metacharacters(
self, mock_subprocess, mock_find_claude
):
"""Test that claude-code install properly escapes server names with shell metacharacters."""
from fastmcp.cli.install.claude_code import install_claude_code

# Mock claude command as available
mock_find_claude.return_value = "/usr/bin/claude"
mock_subprocess.return_value = MagicMock(returncode=0)

# Test with various dangerous characters
dangerous_names = ["test&calc", "test|whoami", "test;ls", "test$USER"]

for dangerous_name in dangerous_names:
mock_subprocess.reset_mock()

# Call install function
install_claude_code(
file=Path("/tmp/server.py"),
server_object=None,
name=dangerous_name,
)

# Verify subprocess was called
assert mock_subprocess.called
cmd_parts = mock_subprocess.call_args[0][0]

# Verify the name was shell-quoted
expected_quoted = shlex.quote(dangerous_name)
assert expected_quoted in cmd_parts, (
f"Expected {expected_quoted} in command but got {cmd_parts}"
)

# Verify the original unquoted name is NOT in the command
if expected_quoted != dangerous_name:
assert dangerous_name not in cmd_parts, (
f"Unquoted name {dangerous_name} should not appear in command"
)
Loading