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
17 changes: 9 additions & 8 deletions build.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

import PyInstaller.__main__

from config.platform_names import normalize_arch, normalize_system

sep = os.pathsep


Expand Down Expand Up @@ -66,17 +68,11 @@ def build_version_file():


def normalize_architecture(arch: str) -> str:
if arch in ['x86_64', 'amd64']:
return 'amd64'
elif arch in ['aarch64', 'arm64']:
return 'arm64'
elif arch.startswith('arm'):
return 'arm64'
return arch
return normalize_arch(arch)


def normalize_system_name(system: str) -> str:
return "macos" if system == 'darwin' else system
return normalize_system(system)


def get_binary_name(system: str, arch: str) -> str:
Expand All @@ -99,6 +95,11 @@ def run_pyinstaller(name: str, icon: str):
"tools.ai_scriptless.step_path",
"tools.ai_scriptless.tree",
"tools.ai_scriptless.variables",
"update",
"update.flow",
"update.install",
"update.processes",
"update.release",
]
PyInstaller.__main__.run([
'main.py',
Expand Down
17 changes: 17 additions & 0 deletions config/http.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""Shared HTTP client defaults for Perfecto MCP (no dependency on tools.*)."""

import platform

import httpx

from config.version import __version__

_ua_part = f"{platform.system()} {platform.release()}; {platform.machine()}"
user_agent = f"perfecto-mcp/{__version__} ({_ua_part})"

timeout = httpx.Timeout(
connect=15.0,
read=60.0,
write=15.0,
pool=60.0,
)
19 changes: 19 additions & 0 deletions config/platform_names.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""Platform name normalization shared by packaging and the updater."""


def normalize_system(system: str) -> str:
system = system.lower()
if system == "darwin":
return "macos"
if system == "windows":
return "windows"
return "linux"


def normalize_arch(machine: str) -> str:
machine = machine.lower()
if machine in {"x86_64", "amd64"}:
return "amd64"
if machine in {"aarch64", "arm64"} or machine.startswith("arm"):
return "arm64"
return machine
56 changes: 41 additions & 15 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import sys
from typing import Literal, cast

from mcp.server.fastmcp import FastMCP, Icon
from mcp.server.fastmcp import FastMCP

from config.perfecto import SECURITY_TOKEN_FILE_ENV_NAME, SECURITY_TOKEN_ENV_NAME, PERFECTO_CLOUD_NAME_ENV_NAME, \
GITHUB
Expand All @@ -32,6 +32,19 @@ def init_logging(level_name: str) -> None:
)


def _banner() -> str:
return (
" _____ __ _ \n"
" | __ \\ / _| | | \n"
" | |__) |__ _ __| |_ ___ ___| |_ ___ \n"
" | ___/ _ \\ '__| _/ _ \\/ __| __/ _ \\ \n"
" | | | __/ | | || __/ (__| || (_) |\n"
" |_| \\___|_| |_| \\___|\\___|\\__\\___/ \n"
" \n"
f" Perfecto MCP Server v{__version__} \n"
)


def get_token() -> PerfectoToken:
global PERFECTO_SECURITY_TOKEN_FILE_PATH, PERFECTO_SECURITY_TOKEN, PERFECTO_CLOUD_NAME, PERFECTO_SECURITY_TOKEN_FILE_NAME

Expand Down Expand Up @@ -93,6 +106,12 @@ def main():
help="Execute MCP Server"
)

parser.add_argument(
"--update",
action="store_true",
help="Run the interactive manual updater (frozen binary builds only)"
)

parser.add_argument(
"--log-level",
default="CRITICAL", # By default, only critical errors
Expand All @@ -105,18 +124,15 @@ def main():
if args.mcp:
init_logging(args.log_level)
run(log_level=args.log_level.upper())
elif args.update:
from update.flow import run_interactive_update

logo_ascii = _banner()
print(logo_ascii)
raise SystemExit(run_interactive_update())
else:

logo_ascii = (
" _____ __ _ \n"
" | __ \ / _| | | \n"
" | |__) |__ _ __| |_ ___ ___| |_ ___ \n"
" | ___/ _ \ '__| _/ _ \/ __| __/ _ \ \n"
" | | | __/ | | || __/ (__| || (_) |\n"
" |_| \___|_| |_| \___|\___|\__\___/ \n"
" \n"
f" Perfecto MCP Server v{__version__} \n"
)
logo_ascii = _banner()
print(logo_ascii)

if PERFECTO_CLOUD_NAME is None:
Expand All @@ -129,9 +145,9 @@ def main():
else:
command_path = __executable__
command = "uvx" if __uvx__ else command_path
args = ["--mcp"]
mcp_args = ["--mcp"]
if __uvx__:
args = [
mcp_args = [
"--from", f"git+{GITHUB}.git@v{get_version()}",
"-q", "perfecto-mcp",
"--mcp"
Expand All @@ -140,7 +156,7 @@ def main():
config_dict = {
"Perfecto MCP": {
"command": f"{command}",
"args": args,
"args": mcp_args,
"env": {
f"{PERFECTO_CLOUD_NAME_ENV_NAME}": f"{perfecto_environment_str}"
}
Expand Down Expand Up @@ -171,7 +187,17 @@ def main():
print(" https://github.com/PerfectoCode/perfecto-mcp/")
print(" ")

input("Press Enter to exit...")
if getattr(sys, "frozen", False) and not __uvx__:
print(" Updates:")
print(" To install a newer Perfecto MCP build, quit every MCP client using this server,")
print(" then re-run this app with --update (or choose Update below) and follow the prompts.")
print(" ")
choice = input("Press Enter to exit, or type 'u' + Enter to check for updates: ").strip().lower()
if choice in {"u", "update"}:
from update.flow import run_interactive_update
raise SystemExit(run_interactive_update())
else:
input("Press Enter to exit...")


if __name__ == "__main__":
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "perfecto-mcp"
version = "1.1.1"
version = "1.1.0"
description = "MCP server for Perfecto Cloud Platform"
readme = "README.md"
requires-python = ">=3.11"
Expand Down Expand Up @@ -33,7 +33,7 @@ dev = [

[tool.setuptools.packages.find]
where = ["."]
include = ["tools*", "config", "models", "formatters", "resources"]
include = ["tools*", "config", "models", "formatters", "resources", "update"]

[tool.setuptools.package-data]
"resources" = ["*.png"]
Expand Down
70 changes: 58 additions & 12 deletions tests/test_tools_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,9 @@

from tools.tools_manager import (
ToolsManager,
_match_recommended_asset,
_normalize_arch,
_normalize_system,
)
from config.platform_names import normalize_arch, normalize_system
from update.release import match_recommended_asset


def _make_ctx():
Expand All @@ -46,13 +45,13 @@ def test_version_returns_current_build_metadata(perfecto_token):


def test_normalize_platform_helpers():
assert _normalize_system("Darwin") == "macos"
assert _normalize_system("Windows") == "windows"
assert _normalize_system("Linux") == "linux"
assert _normalize_arch("x86_64") == "amd64"
assert _normalize_arch("amd64") == "amd64"
assert _normalize_arch("arm64") == "arm64"
assert _normalize_arch("aarch64") == "arm64"
assert normalize_system("Darwin") == "macos"
assert normalize_system("Windows") == "windows"
assert normalize_system("Linux") == "linux"
assert normalize_arch("x86_64") == "amd64"
assert normalize_arch("amd64") == "amd64"
assert normalize_arch("arm64") == "arm64"
assert normalize_arch("aarch64") == "arm64"


def test_match_recommended_asset_prefers_zip():
Expand All @@ -66,7 +65,7 @@ def test_match_recommended_asset_prefers_zip():
"browser_download_url": "https://example.com/zip",
},
]
matched = _match_recommended_asset(assets, "macos", "arm64")
matched = match_recommended_asset(assets, "macos", "arm64")
assert matched["name"] == "perfecto-mcp-macos-arm64.zip"


Expand Down Expand Up @@ -97,10 +96,21 @@ def test_check_updates_when_latest_is_newer(perfecto_token):
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)

runtime = {
"frozen": True,
"uvx": False,
"docker": False,
"executable": "/tmp/perfecto-mcp",
"bundle": "/tmp/perfecto-mcp.app",
}

with patch("tools.tools_manager.httpx.AsyncClient", return_value=mock_client), \
patch("tools.tools_manager.__version__", "1.0.0"), \
patch("tools.tools_manager.platform.system", return_value="Darwin"), \
patch("tools.tools_manager.platform.machine", return_value="arm64"):
patch("tools.tools_manager.platform.machine", return_value="arm64"), \
patch("update.release.platform.system", return_value="Darwin"), \
patch("update.release.platform.machine", return_value="arm64"), \
patch("tools.tools_manager._detect_runtime", return_value=runtime):
manager = ToolsManager(perfecto_token, _make_ctx())
result = asyncio.run(manager.check_updates())

Expand All @@ -112,6 +122,42 @@ def test_check_updates_when_latest_is_newer(perfecto_token):
assert payload["release"]["body"] == "Release notes for 9.9.9"
assert payload["recommended_asset"]["name"] == "perfecto-mcp-macos-arm64.zip"
assert payload["update_guidance"]["status"] == "update_available"
assert "double-click" in payload["update_guidance"]["automatic"].lower()
assert any("update_status" in message for message in result.info)


def test_check_updates_source_runtime_guidance(perfecto_token):
release = {
"tag_name": "v9.9.9",
"name": "v9.9.9",
"html_url": "https://github.com/PerfectoCode/perfecto-mcp/releases/tag/v9.9.9",
"published_at": "2026-07-14T00:00:00Z",
"body": "notes",
"assets": [],
}
mock_response = MagicMock()
mock_response.raise_for_status = MagicMock()
mock_response.json = MagicMock(return_value=release)
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_response)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)

runtime = {
"frozen": False,
"uvx": False,
"docker": False,
"executable": "main.py",
"bundle": "main.py",
}
with patch("tools.tools_manager.httpx.AsyncClient", return_value=mock_client), \
patch("tools.tools_manager.__version__", "1.0.0"), \
patch("tools.tools_manager._detect_runtime", return_value=runtime):
result = asyncio.run(ToolsManager(perfecto_token, _make_ctx()).check_updates())

automatic = result.result[0]["update_guidance"]["automatic"].lower()
assert "frozen" in automatic or "source" in automatic
assert "double-click" not in automatic


def test_check_updates_when_up_to_date(perfecto_token):
Expand Down
Loading