xenfra-sdk 0.2.2__py3-none-any.whl → 0.2.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- xenfra_sdk/__init__.py +61 -21
- xenfra_sdk/cli/main.py +226 -226
- xenfra_sdk/client.py +90 -90
- xenfra_sdk/config.py +26 -26
- xenfra_sdk/db/models.py +24 -24
- xenfra_sdk/db/session.py +30 -30
- xenfra_sdk/dependencies.py +39 -39
- xenfra_sdk/detection.py +396 -0
- xenfra_sdk/dockerizer.py +195 -194
- xenfra_sdk/engine.py +741 -619
- xenfra_sdk/exceptions.py +19 -19
- xenfra_sdk/manifest.py +212 -0
- xenfra_sdk/mcp_client.py +154 -154
- xenfra_sdk/models.py +184 -184
- xenfra_sdk/orchestrator.py +666 -0
- xenfra_sdk/patterns.json +13 -13
- xenfra_sdk/privacy.py +153 -153
- xenfra_sdk/recipes.py +26 -26
- xenfra_sdk/resources/base.py +3 -3
- xenfra_sdk/resources/deployments.py +278 -248
- xenfra_sdk/resources/files.py +101 -101
- xenfra_sdk/resources/intelligence.py +102 -95
- xenfra_sdk/security.py +41 -41
- xenfra_sdk/security_scanner.py +431 -0
- xenfra_sdk/templates/Caddyfile.j2 +14 -0
- xenfra_sdk/templates/Dockerfile.j2 +41 -38
- xenfra_sdk/templates/cloud-init.sh.j2 +90 -90
- xenfra_sdk/templates/docker-compose-multi.yml.j2 +29 -0
- xenfra_sdk/templates/docker-compose.yml.j2 +30 -30
- xenfra_sdk-0.2.3.dist-info/METADATA +116 -0
- xenfra_sdk-0.2.3.dist-info/RECORD +38 -0
- xenfra_sdk-0.2.2.dist-info/METADATA +0 -118
- xenfra_sdk-0.2.2.dist-info/RECORD +0 -32
- {xenfra_sdk-0.2.2.dist-info → xenfra_sdk-0.2.3.dist-info}/WHEEL +0 -0
xenfra_sdk/exceptions.py
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
class XenfraError(Exception):
|
|
2
|
-
"""Base exception for all SDK errors."""
|
|
3
|
-
|
|
4
|
-
pass
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
class AuthenticationError(XenfraError):
|
|
8
|
-
"""Raised for issues related to authentication."""
|
|
9
|
-
|
|
10
|
-
pass
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
class XenfraAPIError(XenfraError):
|
|
14
|
-
"""Raised when the API returns a non-2xx status code."""
|
|
15
|
-
|
|
16
|
-
def __init__(self, status_code: int, detail: str):
|
|
17
|
-
self.status_code = status_code
|
|
18
|
-
self.detail = detail
|
|
19
|
-
super().__init__(f"API Error {status_code}: {detail}")
|
|
1
|
+
class XenfraError(Exception):
|
|
2
|
+
"""Base exception for all SDK errors."""
|
|
3
|
+
|
|
4
|
+
pass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AuthenticationError(XenfraError):
|
|
8
|
+
"""Raised for issues related to authentication."""
|
|
9
|
+
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class XenfraAPIError(XenfraError):
|
|
14
|
+
"""Raised when the API returns a non-2xx status code."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, status_code: int, detail: str):
|
|
17
|
+
self.status_code = status_code
|
|
18
|
+
self.detail = detail
|
|
19
|
+
super().__init__(f"API Error {status_code}: {detail}")
|
xenfra_sdk/manifest.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Xenfra Microservices Manifest - Schema and Parser for xenfra.yaml services array.
|
|
3
|
+
|
|
4
|
+
This module defines the Pydantic models for microservices deployment configuration.
|
|
5
|
+
For microservices projects, xenfra.yaml includes a 'services' array:
|
|
6
|
+
|
|
7
|
+
project_name: my-app
|
|
8
|
+
framework: fastapi
|
|
9
|
+
services: # <-- This makes it a microservices project
|
|
10
|
+
- name: users
|
|
11
|
+
port: 8001
|
|
12
|
+
- name: orders
|
|
13
|
+
port: 8002
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import List, Literal, Optional
|
|
18
|
+
|
|
19
|
+
import yaml
|
|
20
|
+
from pydantic import BaseModel, Field, field_validator
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ServiceDefinition(BaseModel):
|
|
24
|
+
"""
|
|
25
|
+
Single service definition in a microservices project.
|
|
26
|
+
|
|
27
|
+
Example in xenfra.yaml:
|
|
28
|
+
services:
|
|
29
|
+
- name: users
|
|
30
|
+
path: ./services/users
|
|
31
|
+
port: 8001
|
|
32
|
+
framework: fastapi
|
|
33
|
+
entrypoint: users_api.main:app
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
name: str = Field(..., min_length=1, max_length=50, description="Service name (unique)")
|
|
37
|
+
path: str = Field(default=".", description="Relative path to service directory")
|
|
38
|
+
port: int = Field(..., ge=1, le=65535, description="Service port")
|
|
39
|
+
framework: Literal["fastapi", "flask", "django", "other"] = Field(
|
|
40
|
+
default="fastapi", description="Web framework"
|
|
41
|
+
)
|
|
42
|
+
entrypoint: Optional[str] = Field(
|
|
43
|
+
default=None, description="Application entrypoint (e.g., 'users_api.main:app')"
|
|
44
|
+
)
|
|
45
|
+
command: Optional[str] = Field(
|
|
46
|
+
default=None, description="Custom start command"
|
|
47
|
+
)
|
|
48
|
+
env: Optional[dict] = Field(
|
|
49
|
+
default_factory=dict, description="Environment variables"
|
|
50
|
+
)
|
|
51
|
+
package_manager: Optional[str] = Field(
|
|
52
|
+
default="pip", description="Package manager (pip, uv)"
|
|
53
|
+
)
|
|
54
|
+
dependency_file: Optional[str] = Field(
|
|
55
|
+
default="requirements.txt", description="Dependency file"
|
|
56
|
+
)
|
|
57
|
+
missing_deps: List[str] = Field(
|
|
58
|
+
default_factory=list, description="Proactively detected missing dependencies"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
@field_validator("name")
|
|
62
|
+
@classmethod
|
|
63
|
+
def validate_name(cls, v: str) -> str:
|
|
64
|
+
"""Ensure name is URL-safe (alphanumeric + hyphens)."""
|
|
65
|
+
import re
|
|
66
|
+
if not re.match(r'^[a-z][a-z0-9-]*$', v.lower()):
|
|
67
|
+
raise ValueError(
|
|
68
|
+
f"Service name '{v}' must start with a letter and contain only "
|
|
69
|
+
"lowercase letters, numbers, and hyphens"
|
|
70
|
+
)
|
|
71
|
+
return v.lower()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def validate_unique_names(services: List[ServiceDefinition]) -> List[ServiceDefinition]:
|
|
75
|
+
"""Ensure all service names are unique."""
|
|
76
|
+
names = [s.name for s in services]
|
|
77
|
+
if len(names) != len(set(names)):
|
|
78
|
+
duplicates = [n for n in names if names.count(n) > 1]
|
|
79
|
+
raise ValueError(f"Duplicate service names: {set(duplicates)}")
|
|
80
|
+
return services
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def validate_unique_ports(services: List[ServiceDefinition]) -> List[ServiceDefinition]:
|
|
84
|
+
"""Ensure all service ports are unique."""
|
|
85
|
+
ports = [s.port for s in services]
|
|
86
|
+
if len(ports) != len(set(ports)):
|
|
87
|
+
duplicates = [p for p in ports if ports.count(p) > 1]
|
|
88
|
+
raise ValueError(f"Duplicate service ports: {set(duplicates)}")
|
|
89
|
+
return services
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def load_services_from_xenfra_yaml(project_path: str = ".") -> Optional[List[ServiceDefinition]]:
|
|
93
|
+
"""
|
|
94
|
+
Load services array from xenfra.yaml if present.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
project_path: Path to the project directory (default: current directory)
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
List of ServiceDefinition if 'services' key found, None otherwise
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
ValueError: If services array is invalid
|
|
104
|
+
"""
|
|
105
|
+
yaml_path = Path(project_path) / "xenfra.yaml"
|
|
106
|
+
|
|
107
|
+
if not yaml_path.exists():
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
with open(yaml_path, "r", encoding="utf-8") as f:
|
|
111
|
+
try:
|
|
112
|
+
data = yaml.safe_load(f)
|
|
113
|
+
except yaml.YAMLError:
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
if not data or "services" not in data:
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
services_data = data.get("services", [])
|
|
120
|
+
if not services_data or not isinstance(services_data, list):
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
services = [ServiceDefinition(**svc) for svc in services_data]
|
|
125
|
+
validate_unique_names(services)
|
|
126
|
+
validate_unique_ports(services)
|
|
127
|
+
return services
|
|
128
|
+
except Exception as e:
|
|
129
|
+
raise ValueError(f"Invalid services configuration in xenfra.yaml: {e}")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def is_microservices_project(project_path: str = ".") -> bool:
|
|
133
|
+
"""
|
|
134
|
+
Check if project has multiple services defined in xenfra.yaml.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
True if xenfra.yaml has 'services' array with 2+ services
|
|
138
|
+
"""
|
|
139
|
+
try:
|
|
140
|
+
services = load_services_from_xenfra_yaml(project_path)
|
|
141
|
+
return services is not None and len(services) > 1
|
|
142
|
+
except ValueError:
|
|
143
|
+
return False
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def get_deployment_mode(project_path: str = ".") -> Optional[str]:
|
|
147
|
+
"""
|
|
148
|
+
Get deployment mode from xenfra.yaml if specified.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
"single-droplet", "multi-droplet", or None if not specified
|
|
152
|
+
"""
|
|
153
|
+
yaml_path = Path(project_path) / "xenfra.yaml"
|
|
154
|
+
|
|
155
|
+
if not yaml_path.exists():
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
with open(yaml_path, "r", encoding="utf-8") as f:
|
|
159
|
+
try:
|
|
160
|
+
data = yaml.safe_load(f)
|
|
161
|
+
except yaml.YAMLError:
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
return data.get("mode") if data else None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def add_services_to_xenfra_yaml(
|
|
168
|
+
project_path: str,
|
|
169
|
+
services: List[dict],
|
|
170
|
+
mode: str = "single-droplet"
|
|
171
|
+
) -> Path:
|
|
172
|
+
"""
|
|
173
|
+
Add or update services array in existing xenfra.yaml.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
project_path: Path to the project directory
|
|
177
|
+
services: List of service dictionaries from auto-detection
|
|
178
|
+
mode: Deployment mode
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
Path to the updated xenfra.yaml
|
|
182
|
+
"""
|
|
183
|
+
yaml_path = Path(project_path) / "xenfra.yaml"
|
|
184
|
+
|
|
185
|
+
# Load existing xenfra.yaml if present
|
|
186
|
+
existing_data = {}
|
|
187
|
+
if yaml_path.exists():
|
|
188
|
+
with open(yaml_path, "r", encoding="utf-8") as f:
|
|
189
|
+
existing_data = yaml.safe_load(f) or {}
|
|
190
|
+
|
|
191
|
+
# Add services array
|
|
192
|
+
existing_data["services"] = services
|
|
193
|
+
existing_data["mode"] = mode
|
|
194
|
+
|
|
195
|
+
# Write back
|
|
196
|
+
with open(yaml_path, "w", encoding="utf-8") as f:
|
|
197
|
+
yaml.dump(existing_data, f, default_flow_style=False, sort_keys=False)
|
|
198
|
+
|
|
199
|
+
return yaml_path
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def create_services_from_detected(services: List[dict]) -> List[ServiceDefinition]:
|
|
203
|
+
"""
|
|
204
|
+
Create ServiceDefinition list from detected services.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
services: List of service dictionaries from auto-detection
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
List of ServiceDefinition instances
|
|
211
|
+
"""
|
|
212
|
+
return [ServiceDefinition(**svc) for svc in services]
|
xenfra_sdk/mcp_client.py
CHANGED
|
@@ -1,154 +1,154 @@
|
|
|
1
|
-
# src/xenfra/mcp_client.py
|
|
2
|
-
|
|
3
|
-
import base64
|
|
4
|
-
import json
|
|
5
|
-
import os
|
|
6
|
-
import subprocess
|
|
7
|
-
import tempfile
|
|
8
|
-
from pathlib import Path
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
class MCPClient:
|
|
12
|
-
"""
|
|
13
|
-
A client for communicating with a local github-mcp-server process.
|
|
14
|
-
|
|
15
|
-
This client starts the MCP server as a subprocess and interacts with it
|
|
16
|
-
over stdin and stdout to download a full repository to a temporary directory.
|
|
17
|
-
"""
|
|
18
|
-
|
|
19
|
-
def __init__(self, mcp_server_path="github-mcp-server"):
|
|
20
|
-
"""
|
|
21
|
-
Initializes the MCPClient.
|
|
22
|
-
|
|
23
|
-
Args:
|
|
24
|
-
mcp_server_path (str): The path to the github-mcp-server executable.
|
|
25
|
-
Assumes it's in the system's PATH by default.
|
|
26
|
-
"""
|
|
27
|
-
self.mcp_server_path = mcp_server_path
|
|
28
|
-
self.process = None
|
|
29
|
-
|
|
30
|
-
def _start_server(self):
|
|
31
|
-
"""Starts the github-mcp-server subprocess."""
|
|
32
|
-
if self.process and self.process.poll() is None:
|
|
33
|
-
return
|
|
34
|
-
try:
|
|
35
|
-
self.process = subprocess.Popen(
|
|
36
|
-
[self.mcp_server_path],
|
|
37
|
-
stdin=subprocess.PIPE,
|
|
38
|
-
stdout=subprocess.PIPE,
|
|
39
|
-
stderr=subprocess.PIPE,
|
|
40
|
-
text=True,
|
|
41
|
-
env=os.environ,
|
|
42
|
-
)
|
|
43
|
-
except FileNotFoundError:
|
|
44
|
-
raise RuntimeError(
|
|
45
|
-
f"'{self.mcp_server_path}' not found. Ensure github-mcp-server is installed and in your PATH."
|
|
46
|
-
)
|
|
47
|
-
except Exception as e:
|
|
48
|
-
raise RuntimeError(f"Failed to start MCP server: {e}")
|
|
49
|
-
|
|
50
|
-
def _stop_server(self):
|
|
51
|
-
"""Stops the MCP server process."""
|
|
52
|
-
if self.process:
|
|
53
|
-
self.process.terminate()
|
|
54
|
-
self.process.wait()
|
|
55
|
-
self.process = None
|
|
56
|
-
|
|
57
|
-
def _send_request(self, method: str, params: dict) -> dict:
|
|
58
|
-
"""Sends a JSON-RPC request and returns the response."""
|
|
59
|
-
if not self.process or self.process.poll() is not None:
|
|
60
|
-
self._start_server()
|
|
61
|
-
|
|
62
|
-
request = {"jsonrpc": "2.0", "id": os.urandom(4).hex(), "method": method, "params": params}
|
|
63
|
-
|
|
64
|
-
try:
|
|
65
|
-
self.process.stdin.write(json.dumps(request) + "\n")
|
|
66
|
-
self.process.stdin.flush()
|
|
67
|
-
response_line = self.process.stdout.readline()
|
|
68
|
-
if not response_line:
|
|
69
|
-
error_output = self.process.stderr.read()
|
|
70
|
-
raise RuntimeError(f"MCP server closed stream unexpectedly. Error: {error_output}")
|
|
71
|
-
|
|
72
|
-
response = json.loads(response_line)
|
|
73
|
-
if "error" in response:
|
|
74
|
-
raise RuntimeError(f"MCP server returned an error: {response['error']}")
|
|
75
|
-
return response.get("result", {})
|
|
76
|
-
except (BrokenPipeError, json.JSONDecodeError) as e:
|
|
77
|
-
raise RuntimeError(f"Failed to communicate with MCP server: {e}")
|
|
78
|
-
|
|
79
|
-
def download_repo_to_tempdir(self, repo_url: str, commit_sha: str = "HEAD") -> str:
|
|
80
|
-
"""
|
|
81
|
-
Downloads an entire repository at a specific commit to a local temporary directory.
|
|
82
|
-
|
|
83
|
-
Args:
|
|
84
|
-
repo_url (str): The full URL of the GitHub repository.
|
|
85
|
-
commit_sha (str): The commit SHA to download. Defaults to "HEAD".
|
|
86
|
-
|
|
87
|
-
Returns:
|
|
88
|
-
The path to the temporary directory containing the downloaded code.
|
|
89
|
-
"""
|
|
90
|
-
try:
|
|
91
|
-
parts = repo_url.strip("/").split("/")
|
|
92
|
-
owner = parts[-2]
|
|
93
|
-
repo_name = parts[-1].replace(".git", "")
|
|
94
|
-
except IndexError:
|
|
95
|
-
raise ValueError(
|
|
96
|
-
"Invalid repository URL format. Expected format: https://github.com/owner/repo"
|
|
97
|
-
)
|
|
98
|
-
|
|
99
|
-
print(f" [MCP] Fetching file tree for {owner}/{repo_name} at {commit_sha}...")
|
|
100
|
-
tree_result = self._send_request(
|
|
101
|
-
method="git.get_repository_tree",
|
|
102
|
-
params={"owner": owner, "repo": repo_name, "tree_sha": commit_sha, "recursive": True},
|
|
103
|
-
)
|
|
104
|
-
|
|
105
|
-
tree = tree_result.get("tree", [])
|
|
106
|
-
if not tree:
|
|
107
|
-
raise RuntimeError("Could not retrieve repository file tree.")
|
|
108
|
-
|
|
109
|
-
temp_dir = tempfile.mkdtemp(prefix=f"xenfra_{repo_name}_")
|
|
110
|
-
print(f" [MCP] Downloading to temporary directory: {temp_dir}")
|
|
111
|
-
|
|
112
|
-
for item in tree:
|
|
113
|
-
item_path = item.get("path")
|
|
114
|
-
item_type = item.get("type")
|
|
115
|
-
|
|
116
|
-
if not item_path or item_type != "blob": # Only handle files
|
|
117
|
-
continue
|
|
118
|
-
|
|
119
|
-
# For downloading content, we can use the commit_sha in the 'ref' parameter
|
|
120
|
-
# to ensure we get the content from the correct version.
|
|
121
|
-
content_result = self._send_request(
|
|
122
|
-
method="repos.get_file_contents",
|
|
123
|
-
params={"owner": owner, "repo": repo_name, "path": item_path, "ref": commit_sha},
|
|
124
|
-
)
|
|
125
|
-
|
|
126
|
-
content_b64 = content_result.get("content")
|
|
127
|
-
if content_b64 is None:
|
|
128
|
-
print(f" [MCP] [Warning] Could not get content for {item_path}")
|
|
129
|
-
continue
|
|
130
|
-
|
|
131
|
-
try:
|
|
132
|
-
# Content is base64 encoded, with newlines.
|
|
133
|
-
decoded_content = base64.b64decode(content_b64.replace("\n", ""))
|
|
134
|
-
except (base64.binascii.Error, TypeError):
|
|
135
|
-
print(f" [MCP] [Warning] Could not decode content for {item_path}")
|
|
136
|
-
continue
|
|
137
|
-
|
|
138
|
-
# Create file and parent directories in the temp location
|
|
139
|
-
local_file_path = Path(temp_dir) / item_path
|
|
140
|
-
local_file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
141
|
-
|
|
142
|
-
# Write the file content
|
|
143
|
-
with open(local_file_path, "wb") as f:
|
|
144
|
-
f.write(decoded_content)
|
|
145
|
-
|
|
146
|
-
print(" [MCP] ✅ Repository download complete.")
|
|
147
|
-
return temp_dir
|
|
148
|
-
|
|
149
|
-
def __enter__(self):
|
|
150
|
-
self._start_server()
|
|
151
|
-
return self
|
|
152
|
-
|
|
153
|
-
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
154
|
-
self._stop_server()
|
|
1
|
+
# src/xenfra/mcp_client.py
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MCPClient:
|
|
12
|
+
"""
|
|
13
|
+
A client for communicating with a local github-mcp-server process.
|
|
14
|
+
|
|
15
|
+
This client starts the MCP server as a subprocess and interacts with it
|
|
16
|
+
over stdin and stdout to download a full repository to a temporary directory.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, mcp_server_path="github-mcp-server"):
|
|
20
|
+
"""
|
|
21
|
+
Initializes the MCPClient.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
mcp_server_path (str): The path to the github-mcp-server executable.
|
|
25
|
+
Assumes it's in the system's PATH by default.
|
|
26
|
+
"""
|
|
27
|
+
self.mcp_server_path = mcp_server_path
|
|
28
|
+
self.process = None
|
|
29
|
+
|
|
30
|
+
def _start_server(self):
|
|
31
|
+
"""Starts the github-mcp-server subprocess."""
|
|
32
|
+
if self.process and self.process.poll() is None:
|
|
33
|
+
return
|
|
34
|
+
try:
|
|
35
|
+
self.process = subprocess.Popen(
|
|
36
|
+
[self.mcp_server_path],
|
|
37
|
+
stdin=subprocess.PIPE,
|
|
38
|
+
stdout=subprocess.PIPE,
|
|
39
|
+
stderr=subprocess.PIPE,
|
|
40
|
+
text=True,
|
|
41
|
+
env=os.environ,
|
|
42
|
+
)
|
|
43
|
+
except FileNotFoundError:
|
|
44
|
+
raise RuntimeError(
|
|
45
|
+
f"'{self.mcp_server_path}' not found. Ensure github-mcp-server is installed and in your PATH."
|
|
46
|
+
)
|
|
47
|
+
except Exception as e:
|
|
48
|
+
raise RuntimeError(f"Failed to start MCP server: {e}")
|
|
49
|
+
|
|
50
|
+
def _stop_server(self):
|
|
51
|
+
"""Stops the MCP server process."""
|
|
52
|
+
if self.process:
|
|
53
|
+
self.process.terminate()
|
|
54
|
+
self.process.wait()
|
|
55
|
+
self.process = None
|
|
56
|
+
|
|
57
|
+
def _send_request(self, method: str, params: dict) -> dict:
|
|
58
|
+
"""Sends a JSON-RPC request and returns the response."""
|
|
59
|
+
if not self.process or self.process.poll() is not None:
|
|
60
|
+
self._start_server()
|
|
61
|
+
|
|
62
|
+
request = {"jsonrpc": "2.0", "id": os.urandom(4).hex(), "method": method, "params": params}
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
self.process.stdin.write(json.dumps(request) + "\n")
|
|
66
|
+
self.process.stdin.flush()
|
|
67
|
+
response_line = self.process.stdout.readline()
|
|
68
|
+
if not response_line:
|
|
69
|
+
error_output = self.process.stderr.read()
|
|
70
|
+
raise RuntimeError(f"MCP server closed stream unexpectedly. Error: {error_output}")
|
|
71
|
+
|
|
72
|
+
response = json.loads(response_line)
|
|
73
|
+
if "error" in response:
|
|
74
|
+
raise RuntimeError(f"MCP server returned an error: {response['error']}")
|
|
75
|
+
return response.get("result", {})
|
|
76
|
+
except (BrokenPipeError, json.JSONDecodeError) as e:
|
|
77
|
+
raise RuntimeError(f"Failed to communicate with MCP server: {e}")
|
|
78
|
+
|
|
79
|
+
def download_repo_to_tempdir(self, repo_url: str, commit_sha: str = "HEAD") -> str:
|
|
80
|
+
"""
|
|
81
|
+
Downloads an entire repository at a specific commit to a local temporary directory.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
repo_url (str): The full URL of the GitHub repository.
|
|
85
|
+
commit_sha (str): The commit SHA to download. Defaults to "HEAD".
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
The path to the temporary directory containing the downloaded code.
|
|
89
|
+
"""
|
|
90
|
+
try:
|
|
91
|
+
parts = repo_url.strip("/").split("/")
|
|
92
|
+
owner = parts[-2]
|
|
93
|
+
repo_name = parts[-1].replace(".git", "")
|
|
94
|
+
except IndexError:
|
|
95
|
+
raise ValueError(
|
|
96
|
+
"Invalid repository URL format. Expected format: https://github.com/owner/repo"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
print(f" [MCP] Fetching file tree for {owner}/{repo_name} at {commit_sha}...")
|
|
100
|
+
tree_result = self._send_request(
|
|
101
|
+
method="git.get_repository_tree",
|
|
102
|
+
params={"owner": owner, "repo": repo_name, "tree_sha": commit_sha, "recursive": True},
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
tree = tree_result.get("tree", [])
|
|
106
|
+
if not tree:
|
|
107
|
+
raise RuntimeError("Could not retrieve repository file tree.")
|
|
108
|
+
|
|
109
|
+
temp_dir = tempfile.mkdtemp(prefix=f"xenfra_{repo_name}_")
|
|
110
|
+
print(f" [MCP] Downloading to temporary directory: {temp_dir}")
|
|
111
|
+
|
|
112
|
+
for item in tree:
|
|
113
|
+
item_path = item.get("path")
|
|
114
|
+
item_type = item.get("type")
|
|
115
|
+
|
|
116
|
+
if not item_path or item_type != "blob": # Only handle files
|
|
117
|
+
continue
|
|
118
|
+
|
|
119
|
+
# For downloading content, we can use the commit_sha in the 'ref' parameter
|
|
120
|
+
# to ensure we get the content from the correct version.
|
|
121
|
+
content_result = self._send_request(
|
|
122
|
+
method="repos.get_file_contents",
|
|
123
|
+
params={"owner": owner, "repo": repo_name, "path": item_path, "ref": commit_sha},
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
content_b64 = content_result.get("content")
|
|
127
|
+
if content_b64 is None:
|
|
128
|
+
print(f" [MCP] [Warning] Could not get content for {item_path}")
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
# Content is base64 encoded, with newlines.
|
|
133
|
+
decoded_content = base64.b64decode(content_b64.replace("\n", ""))
|
|
134
|
+
except (base64.binascii.Error, TypeError):
|
|
135
|
+
print(f" [MCP] [Warning] Could not decode content for {item_path}")
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
# Create file and parent directories in the temp location
|
|
139
|
+
local_file_path = Path(temp_dir) / item_path
|
|
140
|
+
local_file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
|
|
142
|
+
# Write the file content
|
|
143
|
+
with open(local_file_path, "wb") as f:
|
|
144
|
+
f.write(decoded_content)
|
|
145
|
+
|
|
146
|
+
print(" [MCP] ✅ Repository download complete.")
|
|
147
|
+
return temp_dir
|
|
148
|
+
|
|
149
|
+
def __enter__(self):
|
|
150
|
+
self._start_server()
|
|
151
|
+
return self
|
|
152
|
+
|
|
153
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
154
|
+
self._stop_server()
|