Skip to content

Commit de7c53b

Browse files
committed
Add Perfecto MCP version info and GitHub update-check tools
1 parent 5352f45 commit de7c53b

4 files changed

Lines changed: 583 additions & 3 deletions

File tree

config/perfecto.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
TOOLS_PREFIX: str = "perfecto"
2-
WEBSITE: str = "https://github.com/PerfectoCore/perfecto-mcp/"
3-
GITHUB: str = "https://github.com/PerfectoCore/perfecto-mcp"
4-
SUPPORT_MESSAGE: str = "If you think this is a bug, please contact Perfecto support or report issue at https://github.com/PerfectoCore/perfecto-mcp/issues"
2+
WEBSITE: str = "https://github.com/PerfectoCode/perfecto-mcp/"
3+
GITHUB: str = "https://github.com/PerfectoCode/perfecto-mcp"
4+
GITHUB_API_LATEST_RELEASE: str = "https://api.github.com/repos/PerfectoCode/perfecto-mcp/releases/latest"
5+
SUPPORT_MESSAGE: str = "If you think this is a bug, please contact Perfecto support or report issue at https://github.com/PerfectoCode/perfecto-mcp/issues"
56

67
SECURITY_TOKEN_FILE_ENV_NAME: str = "PERFECTO_SECURITY_TOKEN_FILE"
78
SECURITY_TOKEN_ENV_NAME: str = "PERFECTO_SECURITY_TOKEN"

server.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from tools.device_manager import register as register_device_manager
66
from tools.execution_manager import register as register_execution_manager
77
from tools.help_manager import register as register_help_manager
8+
from tools.tools_manager import register as register_tools_manager
89
from tools.user_manager import register as register_user_manager
910

1011

@@ -21,3 +22,4 @@ def register_tools(mcp, token: Optional[PerfectoToken]):
2122
register_execution_manager(mcp, token)
2223
register_help_manager(mcp, token)
2324
register_ai_scriptless_manager(mcp, token)
25+
register_tools_manager(mcp, token)

tests/test_tools_manager.py

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
"""
2+
Copyright 2025 Perforce Software, Inc.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
"""
16+
import asyncio
17+
from unittest.mock import AsyncMock, MagicMock, patch
18+
19+
import httpx
20+
21+
from tools.tools_manager import (
22+
ToolsManager,
23+
_match_recommended_asset,
24+
_normalize_arch,
25+
_normalize_system,
26+
)
27+
28+
29+
def _make_ctx():
30+
return MagicMock()
31+
32+
33+
def test_version_returns_current_build_metadata(perfecto_token):
34+
manager = ToolsManager(perfecto_token, _make_ctx())
35+
result = asyncio.run(manager.version())
36+
37+
assert result.error is None
38+
assert len(result.result) == 1
39+
payload = result.result[0]
40+
assert payload["version"]
41+
assert payload["user_agent"].startswith(f"perfecto-mcp/{payload['version']}")
42+
assert "platform" in payload
43+
assert "runtime" in payload
44+
assert result.info
45+
assert any("check_updates" in message for message in result.info)
46+
47+
48+
def test_normalize_platform_helpers():
49+
assert _normalize_system("Darwin") == "macos"
50+
assert _normalize_system("Windows") == "windows"
51+
assert _normalize_system("Linux") == "linux"
52+
assert _normalize_arch("x86_64") == "amd64"
53+
assert _normalize_arch("amd64") == "amd64"
54+
assert _normalize_arch("arm64") == "arm64"
55+
assert _normalize_arch("aarch64") == "arm64"
56+
57+
58+
def test_match_recommended_asset_prefers_zip():
59+
assets = [
60+
{
61+
"name": "perfecto-mcp-macos-arm64",
62+
"browser_download_url": "https://example.com/bin",
63+
},
64+
{
65+
"name": "perfecto-mcp-macos-arm64.zip",
66+
"browser_download_url": "https://example.com/zip",
67+
},
68+
]
69+
matched = _match_recommended_asset(assets, "macos", "arm64")
70+
assert matched["name"] == "perfecto-mcp-macos-arm64.zip"
71+
72+
73+
def test_check_updates_when_latest_is_newer(perfecto_token):
74+
release = {
75+
"tag_name": "v9.9.9",
76+
"name": "v9.9.9",
77+
"html_url": "https://github.com/PerfectoCode/perfecto-mcp/releases/tag/v9.9.9",
78+
"published_at": "2026-07-14T00:00:00Z",
79+
"body": "Release notes for 9.9.9",
80+
"assets": [
81+
{
82+
"name": "perfecto-mcp-macos-arm64.zip",
83+
"browser_download_url": "https://example.com/perfecto-mcp-macos-arm64.zip",
84+
"size": 123,
85+
"content_type": "application/zip",
86+
"updated_at": "2026-07-14T00:00:00Z",
87+
}
88+
],
89+
}
90+
91+
mock_response = MagicMock()
92+
mock_response.raise_for_status = MagicMock()
93+
mock_response.json = MagicMock(return_value=release)
94+
95+
mock_client = AsyncMock()
96+
mock_client.get = AsyncMock(return_value=mock_response)
97+
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
98+
mock_client.__aexit__ = AsyncMock(return_value=False)
99+
100+
with patch("tools.tools_manager.httpx.AsyncClient", return_value=mock_client), \
101+
patch("tools.tools_manager.__version__", "1.0.0"), \
102+
patch("tools.tools_manager.platform.system", return_value="Darwin"), \
103+
patch("tools.tools_manager.platform.machine", return_value="arm64"):
104+
manager = ToolsManager(perfecto_token, _make_ctx())
105+
result = asyncio.run(manager.check_updates())
106+
107+
assert result.error is None
108+
payload = result.result[0]
109+
assert payload["update_available"] is True
110+
assert payload["current_version"] == "1.0.0"
111+
assert payload["latest_version"] == "9.9.9"
112+
assert payload["release"]["body"] == "Release notes for 9.9.9"
113+
assert payload["recommended_asset"]["name"] == "perfecto-mcp-macos-arm64.zip"
114+
assert payload["update_guidance"]["status"] == "update_available"
115+
116+
117+
def test_check_updates_when_up_to_date(perfecto_token):
118+
release = {
119+
"tag_name": "v1.1.1",
120+
"name": "v1.1.1",
121+
"html_url": "https://github.com/PerfectoCode/perfecto-mcp/releases/tag/v1.1.1",
122+
"published_at": "2026-07-09T00:00:00Z",
123+
"body": "Up to date notes",
124+
"assets": [],
125+
}
126+
127+
mock_response = MagicMock()
128+
mock_response.raise_for_status = MagicMock()
129+
mock_response.json = MagicMock(return_value=release)
130+
131+
mock_client = AsyncMock()
132+
mock_client.get = AsyncMock(return_value=mock_response)
133+
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
134+
mock_client.__aexit__ = AsyncMock(return_value=False)
135+
136+
with patch("tools.tools_manager.httpx.AsyncClient", return_value=mock_client), \
137+
patch("tools.tools_manager.__version__", "1.1.1"):
138+
manager = ToolsManager(perfecto_token, _make_ctx())
139+
result = asyncio.run(manager.check_updates())
140+
141+
assert result.error is None
142+
payload = result.result[0]
143+
assert payload["update_available"] is False
144+
assert payload["update_guidance"]["status"] == "up_to_date"
145+
146+
147+
def test_check_updates_http_error(perfecto_token):
148+
mock_response = MagicMock()
149+
mock_response.status_code = 500
150+
request = httpx.Request("GET", "https://api.github.com")
151+
mock_response.raise_for_status = MagicMock(
152+
side_effect=httpx.HTTPStatusError("boom", request=request, response=mock_response)
153+
)
154+
155+
mock_client = AsyncMock()
156+
mock_client.get = AsyncMock(return_value=mock_response)
157+
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
158+
mock_client.__aexit__ = AsyncMock(return_value=False)
159+
160+
with patch("tools.tools_manager.httpx.AsyncClient", return_value=mock_client):
161+
manager = ToolsManager(perfecto_token, _make_ctx())
162+
result = asyncio.run(manager.check_updates())
163+
164+
assert result.error is None
165+
payload = result.result[0]
166+
assert payload["update_check_status"] == "unavailable"
167+
assert payload["update_available"] is None
168+
assert payload["latest_version"] is None
169+
assert "releases" in payload["releases_url"]
170+
assert result.warning
171+
assert any("Could not reach GitHub" in message for message in result.warning)
172+
assert result.info
173+
assert any("running Perfecto MCP version" in message for message in result.info)
174+
175+
176+
def test_check_updates_connect_error(perfecto_token):
177+
request = httpx.Request("GET", "https://api.github.com")
178+
mock_client = AsyncMock()
179+
mock_client.get = AsyncMock(side_effect=httpx.ConnectError("dns failed", request=request))
180+
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
181+
mock_client.__aexit__ = AsyncMock(return_value=False)
182+
183+
with patch("tools.tools_manager.httpx.AsyncClient", return_value=mock_client), \
184+
patch("tools.tools_manager.__version__", "1.1.1"):
185+
manager = ToolsManager(perfecto_token, _make_ctx())
186+
result = asyncio.run(manager.check_updates())
187+
188+
assert result.error is None
189+
payload = result.result[0]
190+
assert payload["update_check_status"] == "unavailable"
191+
assert payload["current_version"] == "1.1.1"
192+
assert "connect" in payload["reason"].lower() or "blocked" in payload["reason"].lower()
193+
assert payload["update_guidance"]["status"] == "unavailable"
194+
195+
196+
def test_check_updates_timeout(perfecto_token):
197+
request = httpx.Request("GET", "https://api.github.com")
198+
mock_client = AsyncMock()
199+
mock_client.get = AsyncMock(side_effect=httpx.ReadTimeout("timed out", request=request))
200+
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
201+
mock_client.__aexit__ = AsyncMock(return_value=False)
202+
203+
with patch("tools.tools_manager.httpx.AsyncClient", return_value=mock_client):
204+
manager = ToolsManager(perfecto_token, _make_ctx())
205+
result = asyncio.run(manager.check_updates())
206+
207+
assert result.error is None
208+
payload = result.result[0]
209+
assert payload["update_check_status"] == "unavailable"
210+
assert "Timed out" in payload["reason"]
211+
assert any("corporate" in message for message in (result.warning or []))

0 commit comments

Comments
 (0)