mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-10-10 06:53:19 +02:00
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>
608 lines
20 KiB
Python
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()
|