fastmcp/tests/cli/test_install_cli_arguments.py
Jeremiah Lowin c9a9b900df
Pass client CLI arguments literally to Windows .cmd wrappers (#5419)
On Windows, npm installs the `claude` and `gemini` CLIs as `.cmd` wrappers, which run through cmd.exe. When `fastmcp install claude-code` or `fastmcp install gemini-cli` resolves to a `.cmd` or `.bat` file, FastMCP builds the cmd.exe command line itself, using the batch-file quoting from Rust's standard library. Dependencies such as `pandas>=2.0`, entrypoints, paths and environment values reach the CLI exactly as given.

An argument containing a line break is rejected with a clear error, since cmd.exe cannot take one. Native `.exe` CLIs and non-Windows platforms are unchanged. Server-name validation matches the whole string.

Co-authored-by: Bill Easton <williamseaston@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-10-04 09:06:48 -04:00

608 lines
20 KiB
Python

"""Client CLI argument handling for `fastmcp install claude-code|gemini-cli`.
On Windows, npm installs these CLIs as `.cmd` wrappers that run through
cmd.exe. The tests below check that each argument reaches the CLI literally.
"""
import json
import subprocess
import sys
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from pathlib import Path
from types import ModuleType
from typing import Any
from unittest.mock import Mock
import pytest
from fastmcp.cli.install import claude_code, gemini_cli, shared
from fastmcp.cli.install.shared import (
quote_windows_batch_argument,
run_cli_command,
windows_batch_command_line,
)
from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment
CMD_PREFIX = 'cmd.exe /e:on /v:off /d /s /c "'
CMD_OPERATORS = frozenset("&|<>()")
FAKE_CMD_ENVIRONMENT = {
"cd": "C:\\work",
"path": "C:\\Windows\\System32",
"x": "EXPANDED",
}
LITERAL_ARGUMENTS = [
"pandas>=2.0",
"numpy!=1.0",
'foo[extra]>=1,<2; python_version<"3.12"',
"C:\\Program Files (x86)\\x",
"https://example.com/pkg.whl?a=1&b=2#sha256=abc",
"100%",
"%PATH%",
"%PATH:Windows=EXPANDED%",
"%%",
"%x%",
"%x%x%",
"%cd:~,%",
"!x!",
"!x!%x%!x!",
"^caret^",
"a|b",
"a&b",
"a<b",
"a>b",
"(a)",
'say "hi"',
'"',
'""',
"",
" ",
"C:\\dir\\",
'C:\\dir\\"quoted"\\',
'back\\\\"slash',
"API_URL=https://example.com/api?user=me&page=2",
"SEARCH=cats|dogs",
"PATTERN=^[a-z]+$",
"PATTERN=a^&b",
"PATTERN=a^^b",
'GREETING=say "hi" & wave',
'NOTE=5" tall & wide',
'LABEL=a"&b"&c',
"LOG=run>out.txt",
"LOG=run 2>&1",
"FILTER=size<10",
"GROUP=(a&b)|(c&d)",
"PERCENT=%PATH% %x% 100%",
"BANG=!x! hello!",
'CONFIG={"key": "value", "ratio": "50%"}',
'MIXED={"key": "value"} & literal % ! \' ` ; , =',
"NOTE=it's done; ok, thanks = yes",
"café ünïcode",
"tab\tseparated",
]
def expand_percent_command_line(text: str) -> str:
"""Model cmd.exe percent expansion for a `cmd /c` command line.
An undefined `%name%` is kept and scanning resumes at its closing `%`.
`%cd:~,%` expands to an empty string. Any other expansion of a defined
variable inserts that variable's value.
"""
expanded: list[str] = []
index = 0
while index < len(text):
if text[index] != "%":
expanded.append(text[index])
index += 1
continue
end = text.find("%", index + 1)
if end == -1:
expanded.append(text[index:])
break
name, _, modifier = text[index + 1 : end].partition(":")
value = FAKE_CMD_ENVIRONMENT.get(name.lower())
if value is None:
expanded.append("%")
index = end
continue
expanded.append("" if modifier == "~," else value)
index = end + 1
return "".join(expanded)
def parse_cmd_special_characters(text: str) -> str:
"""Model cmd.exe quote and caret handling, failing on unquoted operators."""
parsed: list[str] = []
quoted = False
index = 0
while index < len(text):
char = text[index]
if char == '"':
quoted = not quoted
parsed.append(char)
elif not quoted and char == "^":
index += 1
parsed.append(text[index : index + 1])
elif not quoted and char in CMD_OPERATORS:
raise AssertionError(f"cmd.exe would treat {char!r} as an operator")
else:
parsed.append(char)
index += 1
return "".join(parsed)
def split_c_runtime_arguments(text: str) -> list[str]:
"""Split a command line the way the Microsoft C runtime builds argv."""
arguments: list[str] = []
index, length = 0, len(text)
while True:
while index < length and text[index] in " \t":
index += 1
if index == length:
return arguments
current: list[str] = []
quoted = False
while index < length:
char = text[index]
if char == "\\":
start = index
while index < length and text[index] == "\\":
index += 1
count = index - start
if index < length and text[index] == '"':
current.append("\\" * (count // 2))
if count % 2:
current.append('"')
index += 1
else:
current.append("\\" * count)
continue
if char == '"':
if quoted and text[index + 1 : index + 2] == '"':
current.append('"')
index += 2
else:
quoted = not quoted
index += 1
continue
if char in " \t" and not quoted:
break
current.append(char)
index += 1
arguments.append("".join(current))
def arguments_received_through_batch_file(command_line: str) -> tuple[str, list[str]]:
"""Return the batch file and the argv its program receives.
Models `cmd /s /c`, then a batch file line such as npm's
`"%_prog%" "%dp0%\\cli.js" %*`, which parses the expanded `%*` again.
"""
assert command_line.startswith(CMD_PREFIX)
assert command_line.endswith('"')
inner = command_line[len(CMD_PREFIX) : -1]
after_cmd = parse_cmd_special_characters(expand_percent_command_line(inner))
assert after_cmd.startswith('"')
script_end = after_cmd.index('"', 1) + 1
script = after_cmd[1 : script_end - 1]
batch_line = '"node.exe" "cli.js"' + after_cmd[script_end:]
received = split_c_runtime_arguments(parse_cmd_special_characters(batch_line))
assert received[:2] == ["node.exe", "cli.js"]
return script, received[2:]
class TestQuoteWindowsBatchArgument:
@pytest.mark.parametrize(
"argument,expected",
[
("mcp", "mcp"),
("--", "--"),
("C:\\venv\\Scripts\\uv.exe", "C:\\venv\\Scripts\\uv.exe"),
("café", "café"),
("", '""'),
("a b", '"a b"'),
("pandas>=2.0", '"pandas>=2.0"'),
("C:\\Program Files (x86)\\x", '"C:\\Program Files (x86)\\x"'),
('say "hi"', '"say ""hi"""'),
('a\\"b', '"a\\\\""b"'),
("C:\\dir\\", '"C:\\dir\\\\"'),
("100%", '"100%%cd:~,%"'),
("%x%", '"%%cd:~,%x%%cd:~,%"'),
("!x!", '"!x!"'),
("a&b", '"a&b"'),
("a|b", '"a|b"'),
("a<b", '"a<b"'),
("a>b", '"a>b"'),
("a^b", '"a^b"'),
("(a)", '"(a)"'),
('5" tall & wide', '"5"" tall & wide"'),
],
)
def test_quoted_form(self, argument: str, expected: str):
assert quote_windows_batch_argument(argument) == expected
@pytest.mark.parametrize("argument", ["a\nb", "a\rb", "value\r\n"])
def test_rejects_line_breaks(self, argument: str):
with pytest.raises(ValueError, match="line breaks"):
quote_windows_batch_argument(argument)
class TestWindowsBatchCommandLine:
@pytest.mark.parametrize("argument", LITERAL_ARGUMENTS)
def test_argument_arrives_literally(self, argument: str):
command = ["C:\\npm\\claude.cmd", "mcp", "add", argument, "--", "uv"]
script, received = arguments_received_through_batch_file(
windows_batch_command_line(command)
)
assert script == command[0]
assert received == command[1:]
def test_all_arguments_together_arrive_literally(self):
command = ["C:\\npm\\claude.cmd", *LITERAL_ARGUMENTS]
_, received = arguments_received_through_batch_file(
windows_batch_command_line(command)
)
assert received == LITERAL_ARGUMENTS
@pytest.mark.parametrize(
"script",
[
"C:\\Users\\R&D (x86)\\npm\\claude.cmd",
"C:\\Users\\100%\\npm\\gemini.cmd",
],
)
def test_script_path_arrives_literally(self, script: str):
found, received = arguments_received_through_batch_file(
windows_batch_command_line([script, "%x%"])
)
assert found == script
assert received == ["%x%"]
@pytest.mark.parametrize(
"script", ['C:\\npm\\"claude.cmd', "C:\\npm\\", "C:\\npm\nclaude.cmd"]
)
def test_rejects_invalid_script_path(self, script: str):
with pytest.raises(ValueError, match="batch file path"):
windows_batch_command_line([script, "mcp"])
@dataclass
class Installer:
install: Callable[..., bool]
command: Callable[..., Coroutine[Any, Any, None]]
module: ModuleType
find_command: str
executable_name: str
INSTALLERS = [
Installer(
install=claude_code.install_claude_code,
command=claude_code.claude_code_command,
module=claude_code,
find_command="find_claude_command",
executable_name="claude",
),
Installer(
install=gemini_cli.install_gemini_cli,
command=gemini_cli.gemini_cli_command,
module=gemini_cli,
find_command="find_gemini_command",
executable_name="gemini",
),
]
@pytest.fixture(params=INSTALLERS, ids=["claude-code", "gemini-cli"])
def installer(request: pytest.FixtureRequest) -> Installer:
return request.param
@pytest.fixture
def run(monkeypatch: pytest.MonkeyPatch) -> Mock:
mock = Mock(return_value=subprocess.CompletedProcess([], 0, stdout=""))
monkeypatch.setattr(shared.subprocess, "run", mock)
return mock
def use_platform(
monkeypatch: pytest.MonkeyPatch,
installer: Installer,
platform: str,
executable: str,
) -> None:
monkeypatch.setattr(sys, "platform", platform)
monkeypatch.setattr(installer.module, installer.find_command, lambda: executable)
monkeypatch.setenv("COMSPEC", "C:\\Windows\\System32\\cmd.exe")
class TestInstallerSubprocessBoundary:
def test_batch_wrapper_receives_literal_arguments(
self, installer: Installer, run: Mock, monkeypatch: pytest.MonkeyPatch
):
wrapper = f"C:\\npm\\{installer.executable_name}.cmd"
use_platform(monkeypatch, installer, "win32", wrapper)
assert installer.install(
file=Path("server.py"),
server_object="mcp",
name="server",
with_packages=["pandas>=2.0", 'foo; python_version<"3.12"'],
env_vars={
"API_URL": "https://example.com/?a=1&b=2",
"LOG": "run>out.txt",
"NOTE": '5" tall & wide',
"PATTERN": "a^&b",
"PERCENT": "%PATH% %x%",
"BANG": "!x!",
},
)
run.assert_called_once()
command_line = run.call_args.args[0]
assert isinstance(command_line, str)
assert run.call_args.kwargs["executable"] == "C:\\Windows\\System32\\cmd.exe"
script, received = arguments_received_through_batch_file(command_line)
assert script == wrapper
assert "API_URL=https://example.com/?a=1&b=2" in received
assert "LOG=run>out.txt" in received
assert 'NOTE=5" tall & wide' in received
assert "PATTERN=a^&b" in received
assert "PERCENT=%PATH% %x%" in received
assert "BANG=!x!" in received
assert "pandas>=2.0" in received
assert 'foo; python_version<"3.12"' in received
@pytest.mark.parametrize("extension", [".cmd", ".CMD", ".bat", ".Bat"])
def test_batch_wrapper_detection_ignores_case(
self,
installer: Installer,
run: Mock,
monkeypatch: pytest.MonkeyPatch,
extension: str,
):
wrapper = f"C:\\npm\\{installer.executable_name}{extension}"
use_platform(monkeypatch, installer, "win32", wrapper)
assert installer.install(file=Path("server.py"), server_object=None, name="s")
assert run.call_args.args[0].startswith(CMD_PREFIX)
@pytest.mark.parametrize(
"platform,executable",
[
("win32", "C:\\Program Files\\client\\client.exe"),
("darwin", "/usr/local/bin/client.cmd"),
("linux", "/usr/local/bin/client"),
],
)
def test_other_executables_receive_argument_list(
self,
installer: Installer,
run: Mock,
monkeypatch: pytest.MonkeyPatch,
platform: str,
executable: str,
):
use_platform(monkeypatch, installer, platform, executable)
assert installer.install(
file=Path("server.py"),
server_object=None,
name="server",
env_vars={
"API_URL": "https://example.com/?a=1&b=2 %PATH%",
"LOG": "run>out.txt",
"BANG": "!x! %x%",
},
)
argv = run.call_args.args[0]
assert isinstance(argv, list)
assert argv[0] == executable
assert "API_URL=https://example.com/?a=1&b=2 %PATH%" in argv
assert "LOG=run>out.txt" in argv
assert "BANG=!x! %x%" in argv
assert "executable" not in run.call_args.kwargs
@pytest.mark.parametrize("value", ["hello\nworld", "hello\rworld", "hello\r\n"])
def test_batch_wrapper_rejects_line_break(
self,
installer: Installer,
run: Mock,
monkeypatch: pytest.MonkeyPatch,
value: str,
):
use_platform(
monkeypatch, installer, "win32", f"{installer.executable_name}.cmd"
)
assert not installer.install(
file=Path("server.py"),
server_object=None,
name="server",
env_vars={"GREETING": value},
)
run.assert_not_called()
async def test_config_and_env_file_values_reach_wrapper_literally(
self,
installer: Installer,
run: Mock,
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
):
wrapper = f"C:\\npm\\{installer.executable_name}.cmd"
use_platform(monkeypatch, installer, "win32", wrapper)
(tmp_path / "server.py").write_text(
'from fastmcp import FastMCP\n\nmcp = FastMCP("Analytics")\n'
)
config = tmp_path / "fastmcp.json"
config.write_text(
json.dumps(
{
"source": {"path": "server.py", "entrypoint": "mcp&app"},
"environment": {
"dependencies": [
"pandas>=2.0,!=2.1",
'tomli; python_version<"3.11"',
"pkg @ https://example.com/pkg.whl?a=1&b=2",
]
},
}
)
)
env_file = tmp_path / ".env"
env_file.write_text(
"API_URL='https://example.com/?a=1&b=2 100% !'\n"
"LOG='run>out.txt %PATH% %x% !x!'\n"
)
await installer.command(str(config), server_name="server", env_file=env_file)
_, received = arguments_received_through_batch_file(run.call_args.args[0])
assert "API_URL=https://example.com/?a=1&b=2 100% !" in received
assert "LOG=run>out.txt %PATH% %x% !x!" in received
assert "pandas>=2.0,!=2.1" in received
assert 'tomli; python_version<"3.11"' in received
assert "pkg @ https://example.com/pkg.whl?a=1&b=2" in received
assert f"{(tmp_path / 'server.py').resolve()}:mcp&app" in received
def test_run_cli_command_passes_argument_list(run: Mock):
run_cli_command(["client", "mcp", "add"])
run.assert_called_once_with(
["client", "mcp", "add"], check=True, capture_output=True, text=True
)
@pytest.mark.parametrize(
"comspec,expected",
[
("C:\\Tools\\cmd.exe", "C:\\Tools\\cmd.exe"),
("cmd.exe", "D:\\Win\\System32\\cmd.exe"),
(None, "D:\\Win\\System32\\cmd.exe"),
],
)
def test_batch_file_runs_with_absolute_cmd_path(
run: Mock, monkeypatch: pytest.MonkeyPatch, comspec: str | None, expected: str
):
monkeypatch.setattr(sys, "platform", "win32")
if comspec is None:
monkeypatch.delenv("COMSPEC", raising=False)
else:
monkeypatch.setenv("COMSPEC", comspec)
monkeypatch.setenv("SYSTEMROOT", "D:\\Win")
run_cli_command(["C:\\npm\\claude.cmd", "mcp"])
assert run.call_args.kwargs["executable"] == expected
ARGV_RECORDER = """\
import json
import os
import sys
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "argv.json")
with open(path, "w", encoding="utf-8") as f:
json.dump(sys.argv[1:], f)
"""
# Same structure as the wrappers npm's cmd-shim generates, with Python standing
# in for node.exe. The last line expands `%*`, so cmd.exe parses the
# arguments a second time.
CMD_SHIM = (
"@ECHO off\r\n"
"GOTO start\r\n"
":find_dp0\r\n"
"SET dp0=%~dp0\r\n"
"EXIT /b\r\n"
":start\r\n"
"SETLOCAL\r\n"
"CALL :find_dp0\r\n"
'SET "_prog={python}"\r\n'
"endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "
'"%_prog%" "%dp0%\\record_argv.py" %*\r\n'
)
@pytest.mark.skipif(sys.platform != "win32", reason="Runs a real .cmd file")
@pytest.mark.timeout(30)
def test_native_windows_cmd_wrapper_receives_literal_arguments(
installer: Installer, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
):
wrapper_dir = tmp_path / "npm (x86)"
wrapper_dir.mkdir()
(wrapper_dir / "record_argv.py").write_text(ARGV_RECORDER, encoding="utf-8")
wrapper = wrapper_dir / f"{installer.executable_name}.cmd"
wrapper.write_bytes(CMD_SHIM.replace("{python}", sys.executable).encode("utf-8"))
monkeypatch.setattr(installer.module, installer.find_command, lambda: str(wrapper))
server = tmp_path / "project (1)" / "server.py"
server.parent.mkdir()
server.write_text("")
work_dir = tmp_path / "cwd"
work_dir.mkdir()
monkeypatch.chdir(work_dir)
monkeypatch.setenv("x", "EXPANDED")
env_vars = {
"API_URL": "https://example.com/api?user=me&page=2",
"SEARCH": "cats|dogs",
"PATTERN": "^[a-z]+$",
"ESCAPED": "a^&b",
"GREETING": 'say "hi" & wave',
"NOTE": '5" tall & wide',
"LABEL": 'a"&b"&c',
"LOG": "run>out.txt",
"STDERR": "run 2>&1",
"FILTER": "size<10",
"GROUP": "(a&b)|(c&d)",
"PERCENT": "%PATH% %x% 100%",
"VARIABLE": "%x%",
"BANG": "!x!",
"EXCLAIM": "hello!",
"CONFIG": 'json={"key": "value"}',
"MIXED": '{"key": "value"} & literal % ! \' ` ; , =',
"DATA_DIR": "C:\\data\\",
}
packages = [
"pandas>=2.0",
"numpy!=1.0",
'foo[extra]>=1,<2; python_version<"3.12"',
"pkg @ https://example.com/pkg.whl?a=1&b=2",
]
project = Path("C:\\Program Files (x86)\\x")
assert installer.install(
file=server,
server_object="mcp",
name="server",
with_packages=packages,
env_vars=env_vars,
project=project,
)
full_command = UVEnvironment(
dependencies=[*packages, "fastmcp"], project=project
).build_command(["fastmcp", "run", f"{server.resolve()}:mcp"])
env_arguments = [
argument
for key, value in env_vars.items()
for argument in ("-e", f"{key}={value}")
]
if installer.module is claude_code:
expected = ["mcp", "add", "server", *env_arguments, "--", *full_command]
else:
expected = [
"mcp",
"add",
*env_arguments,
"server",
full_command[0],
"--",
*full_command[1:],
]
received = json.loads((wrapper_dir / "argv.json").read_text(encoding="utf-8"))
assert received == expected
assert list(work_dir.iterdir()) == []
assert not (wrapper_dir / "out.txt").exists()
assert not (server.parent / "out.txt").exists()
assert not (tmp_path / "out.txt").exists()