From cc2e3ab11fba0edd2223b393509933ccf734a92d Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 15 Mar 2026 14:24:25 -0400 Subject: [PATCH 1/3] fix: validate server names in install commands --- src/fastmcp/cli/install/shared.py | 23 +++++++++++++++++++ tests/cli/test_install.py | 37 +++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/src/fastmcp/cli/install/shared.py b/src/fastmcp/cli/install/shared.py index fe980dce6..ce35bd886 100644 --- a/src/fastmcp/cli/install/shared.py +++ b/src/fastmcp/cli/install/shared.py @@ -2,6 +2,7 @@ import json import os +import re import subprocess import sys from pathlib import Path @@ -17,6 +18,26 @@ from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystem logger = get_logger(__name__) +# Server names are passed as subprocess arguments to CLI tools like `claude` +# and `gemini`. On Windows these may resolve to .cmd/.bat wrappers that run +# through cmd.exe, where shell metacharacters (& | ; etc.) in arguments can +# cause command injection. Restrict names to safe characters. +_SAFE_NAME_RE = re.compile(r"^[\w\-. ]+$") + + +def validate_server_name(name: str) -> str: + """Validate that a server name is safe for use as a subprocess argument. + + Raises SystemExit if the name contains shell metacharacters. + """ + if not _SAFE_NAME_RE.match(name): + print( + f"[red]Invalid server name '[bold]{name}[/bold]': " + "names may only contain letters, numbers, hyphens, underscores, dots, and spaces.[/red]" + ) + sys.exit(1) + return name + def parse_env_var(env_var: str) -> tuple[str, str]: """Parse environment variable string in format KEY=VALUE.""" @@ -123,6 +144,8 @@ async def process_common_args( ) name = file.stem + validate_server_name(name) + # Process environment variables if provided env_dict: dict[str, str] | None = None if env_file or env_vars: diff --git a/tests/cli/test_install.py b/tests/cli/test_install.py index 6b354c9b7..9dca455fa 100644 --- a/tests/cli/test_install.py +++ b/tests/cli/test_install.py @@ -1,6 +1,9 @@ from pathlib import Path +import pytest + from fastmcp.cli.install import install_app +from fastmcp.cli.install.shared import validate_server_name from fastmcp.cli.install.stdio import install_stdio @@ -455,3 +458,37 @@ class TestInstallCommandParsing: command, bound, _ = install_app.parse_args(cmd_args) assert command is not None assert str(bound.arguments["project"]) == str(Path("/path/to/project")) + + +class TestServerNameValidation: + """Test server name validation rejects shell metacharacters.""" + + @pytest.mark.parametrize( + "name", + [ + "my-server", + "my_server", + "My Server", + "server.v2", + "test123", + ], + ) + def test_valid_names(self, name: str): + assert validate_server_name(name) == name + + @pytest.mark.parametrize( + "name", + [ + "test&calc", + "test|whoami", + "test;ls", + "test$(id)", + "test`id`", + 'test"quoted', + "test>file", + "test Date: Sun, 15 Mar 2026 14:34:02 -0400 Subject: [PATCH 2/3] Scope validation to shell-backed install paths only --- src/fastmcp/cli/install/claude_code.py | 4 +++- src/fastmcp/cli/install/gemini_cli.py | 4 +++- src/fastmcp/cli/install/shared.py | 2 -- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/fastmcp/cli/install/claude_code.py b/src/fastmcp/cli/install/claude_code.py index a88413a32..5aa377568 100644 --- a/src/fastmcp/cli/install/claude_code.py +++ b/src/fastmcp/cli/install/claude_code.py @@ -12,7 +12,7 @@ from rich import print from fastmcp.utilities.logging import get_logger from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment -from .shared import process_common_args +from .shared import process_common_args, validate_server_name logger = get_logger(__name__) @@ -124,6 +124,8 @@ def install_claude_code( # Build the full command full_command = env_config.build_command(["fastmcp", "run", server_spec]) + validate_server_name(name) + # Build claude mcp add command cmd_parts = [claude_cmd, "mcp", "add", name] diff --git a/src/fastmcp/cli/install/gemini_cli.py b/src/fastmcp/cli/install/gemini_cli.py index 42b6ec778..29cc39e15 100644 --- a/src/fastmcp/cli/install/gemini_cli.py +++ b/src/fastmcp/cli/install/gemini_cli.py @@ -12,7 +12,7 @@ from rich import print from fastmcp.utilities.logging import get_logger from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment -from .shared import process_common_args +from .shared import process_common_args, validate_server_name logger = get_logger(__name__) @@ -129,6 +129,8 @@ def install_gemini_cli( for key, value in env_vars.items(): cmd_parts.extend(["-e", f"{key}={value}"]) + validate_server_name(name) + # Add server name and command cmd_parts.extend([name, full_command[0], "--"]) cmd_parts.extend(full_command[1:]) diff --git a/src/fastmcp/cli/install/shared.py b/src/fastmcp/cli/install/shared.py index ce35bd886..df22f5bbf 100644 --- a/src/fastmcp/cli/install/shared.py +++ b/src/fastmcp/cli/install/shared.py @@ -144,8 +144,6 @@ async def process_common_args( ) name = file.stem - validate_server_name(name) - # Process environment variables if provided env_dict: dict[str, str] | None = None if env_file or env_vars: From 34651661067ddef4ba55e60b6c80d2ec7106bc81 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 15 Mar 2026 14:56:51 -0400 Subject: [PATCH 3/3] fix: use raw strings for regex in pytest.raises match --- tests/tools/tool_transform/test_tool_transform.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/tools/tool_transform/test_tool_transform.py b/tests/tools/tool_transform/test_tool_transform.py index bcf999ae6..cc81f8e8a 100644 --- a/tests/tools/tool_transform/test_tool_transform.py +++ b/tests/tools/tool_transform/test_tool_transform.py @@ -428,13 +428,13 @@ async def test_fn_with_kwargs_dropped_args_not_in_kwargs(add_tool): async def test_forward_outside_context_raises_error(): """Test that forward() raises error when called outside transform context.""" - with pytest.raises(RuntimeError, match="forward\(\) can only be called"): + with pytest.raises(RuntimeError, match=r"forward\(\) can only be called"): await forward(x=1) async def test_forward_raw_outside_context_raises_error(): """Test that forward_raw() raises error when called outside transform context.""" - with pytest.raises(RuntimeError, match="forward_raw\(\) can only be called"): + with pytest.raises(RuntimeError, match=r"forward_raw\(\) can only be called"): await forward_raw(x=1)