ai-coding-gym-mcp 0.1.0__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.
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ai-coding-gym-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for AI Coding Gym - fetch and submit coding challenges
|
|
5
|
+
Home-page: https://github.com/yourusername/ai-coding-gym-mcp
|
|
6
|
+
Author: AICodingGym Team
|
|
7
|
+
Author-email: Your Name <your.email@example.com>
|
|
8
|
+
License: MIT
|
|
9
|
+
Project-URL: Homepage, https://github.com/yourusername/ai-coding-gym-mcp
|
|
10
|
+
Project-URL: Issues, https://github.com/yourusername/ai-coding-gym-mcp/issues
|
|
11
|
+
Keywords: mcp,model-context-protocol,coding-gym,ai
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Requires-Dist: mcp>=0.9.0
|
|
22
|
+
Requires-Dist: requests>=2.31.0
|
|
23
|
+
Dynamic: author
|
|
24
|
+
Dynamic: home-page
|
|
25
|
+
Dynamic: requires-python
|
|
26
|
+
|
|
27
|
+
# AI Coding Gym - MCP Server
|
|
28
|
+
|
|
29
|
+
Local MCP server for interacting with the AI Coding Gym platform. Provides tools to fetch coding problems and submit solutions.
|
|
30
|
+
|
|
31
|
+
## Features
|
|
32
|
+
|
|
33
|
+
- **`/fetch`**: Fetch a coding problem and clone the repository to your local machine
|
|
34
|
+
- **`/submit`**: Submit your solution by committing and pushing changes
|
|
35
|
+
|
|
36
|
+
## Quick Start
|
|
37
|
+
|
|
38
|
+
### Installation
|
|
39
|
+
|
|
40
|
+
**Option 1: Install from PyPI**
|
|
41
|
+
```bash
|
|
42
|
+
pip install ai-coding-gym-mcp
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
**Option 2: Install from GitHub**
|
|
46
|
+
```bash
|
|
47
|
+
pip install git+https://github.com/yourusername/ai-coding-gym-mcp.git
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
**Option 3: Install from source**
|
|
51
|
+
```bash
|
|
52
|
+
git clone https://github.com/yourusername/ai-coding-gym-mcp.git
|
|
53
|
+
cd ai-coding-gym-mcp
|
|
54
|
+
pip install -e .
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Configure Claude Desktop
|
|
58
|
+
|
|
59
|
+
Add to your Claude Desktop config (`~/Library/Application Support/Claude/claude_desktop_config.json` on Mac):
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{
|
|
63
|
+
"mcpServers": {
|
|
64
|
+
"ai-coding-gym": {
|
|
65
|
+
"command": "python",
|
|
66
|
+
"args": ["-m", "server"],
|
|
67
|
+
"env": {
|
|
68
|
+
"AI_CODING_GYM_SERVER": "https://your-server-url.com"
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
See [INSTALLATION.md](INSTALLATION.md) for detailed setup instructions for Claude Desktop and VS Code.
|
|
76
|
+
|
|
77
|
+
## Usage
|
|
78
|
+
|
|
79
|
+
### Running the MCP Server
|
|
80
|
+
|
|
81
|
+
The server uses stdio for communication with MCP clients:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
python server.py
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Or configure it in your MCP client settings (e.g., Claude Desktop).
|
|
88
|
+
|
|
89
|
+
### Tool: `/fetch`
|
|
90
|
+
|
|
91
|
+
Fetches a problem from the backend and clones the repository locally.
|
|
92
|
+
|
|
93
|
+
**Parameters:**
|
|
94
|
+
- `problem_id` (required): Problem identifier (e.g., `"django__django-10097"`)
|
|
95
|
+
- `user_id` (required): Your user ID for authentication
|
|
96
|
+
- `server_url` (optional): Backend server URL (default: `"https://api.example.com"`)
|
|
97
|
+
- `workspace_dir` (optional): Local workspace directory (default: `"./workspace"`)
|
|
98
|
+
|
|
99
|
+
**Example:**
|
|
100
|
+
```json
|
|
101
|
+
{
|
|
102
|
+
"problem_id": "django__django-10097",
|
|
103
|
+
"user_id": "user_123",
|
|
104
|
+
"server_url": "https://ai-coding-gym.example.com"
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
**What it does:**
|
|
109
|
+
1. Calls backend API to get repository URL and deployment key
|
|
110
|
+
2. Clones the repository to `workspace/{problem_id}/`
|
|
111
|
+
3. Checks out the problem-specific branch
|
|
112
|
+
4. Saves the problem statement to `PROBLEM_STATEMENT.md`
|
|
113
|
+
|
|
114
|
+
### Tool: `/submit`
|
|
115
|
+
|
|
116
|
+
Submits your solution by committing changes and pushing to the remote repository.
|
|
117
|
+
|
|
118
|
+
**Parameters:**
|
|
119
|
+
- `problem_id` (required): Problem identifier
|
|
120
|
+
- `user_id` (required): Your user ID for authentication
|
|
121
|
+
- `server_url` (optional): Backend server URL (default: `"https://api.example.com"`)
|
|
122
|
+
- `commit_message` (optional): Custom commit message
|
|
123
|
+
|
|
124
|
+
**Example:**
|
|
125
|
+
```json
|
|
126
|
+
{
|
|
127
|
+
"problem_id": "django__django-10097",
|
|
128
|
+
"user_id": "user_123",
|
|
129
|
+
"commit_message": "Fixed the authentication bug"
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
**What it does:**
|
|
134
|
+
1. Stages all changes in the working directory (`git add -A`)
|
|
135
|
+
2. Commits with the provided or auto-generated message
|
|
136
|
+
3. Pushes to the remote branch using deployment key
|
|
137
|
+
4. Notifies the backend server about the submission
|
|
138
|
+
|
|
139
|
+
## Backend API Endpoints
|
|
140
|
+
|
|
141
|
+
The MCP server expects the following backend API endpoints:
|
|
142
|
+
|
|
143
|
+
### POST `/api/fetch-problem`
|
|
144
|
+
|
|
145
|
+
**Request:**
|
|
146
|
+
```json
|
|
147
|
+
{
|
|
148
|
+
"problem_id": "django__django-10097",
|
|
149
|
+
"user_id": "user_123"
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
**Response:**
|
|
154
|
+
```json
|
|
155
|
+
{
|
|
156
|
+
"repo_url": "git@github.com:org/repo.git",
|
|
157
|
+
"branch": "django__django-10097-user_123",
|
|
158
|
+
"deploy_key": "-----BEGIN OPENSSH PRIVATE KEY-----\n...\n-----END OPENSSH PRIVATE KEY-----",
|
|
159
|
+
"problem_statement": "# Problem Description\n\n..."
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### POST `/api/submit`
|
|
164
|
+
|
|
165
|
+
**Request:**
|
|
166
|
+
```json
|
|
167
|
+
{
|
|
168
|
+
"problem_id": "django__django-10097",
|
|
169
|
+
"user_id": "user_123",
|
|
170
|
+
"commit_hash": "abc123def456...",
|
|
171
|
+
"branch": "django__django-10097-user_123",
|
|
172
|
+
"timestamp": "2026-02-03T10:30:00"
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
**Response:**
|
|
177
|
+
```json
|
|
178
|
+
{
|
|
179
|
+
"status": "success",
|
|
180
|
+
"message": "Submission received"
|
|
181
|
+
}
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Security
|
|
185
|
+
|
|
186
|
+
- Deployment keys are stored in `~/.mcp-keys/` with 600 permissions
|
|
187
|
+
- Keys are scoped per problem and managed by the backend
|
|
188
|
+
- SSH host key checking is disabled for convenience (consider enabling in production)
|
|
189
|
+
- Credentials are cached in memory during the MCP server session
|
|
190
|
+
|
|
191
|
+
## Configuration
|
|
192
|
+
|
|
193
|
+
Default server URL is `https://api.example.com`. You can override it by passing `server_url` parameter to each tool call, or set it via environment variable:
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
export AI_CODING_GYM_SERVER="https://your-server.com"
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
## Troubleshooting
|
|
200
|
+
|
|
201
|
+
**"No credentials found for problem_id"**
|
|
202
|
+
- Run `/fetch` first to download the problem and credentials
|
|
203
|
+
|
|
204
|
+
**"Git clone/push failed"**
|
|
205
|
+
- Check network connectivity
|
|
206
|
+
- Verify deployment key is valid
|
|
207
|
+
- Ensure SSH agent isn't interfering
|
|
208
|
+
|
|
209
|
+
**"Directory already exists"**
|
|
210
|
+
- Remove the existing directory or use a different workspace location
|
|
211
|
+
|
|
212
|
+
## Publishing
|
|
213
|
+
|
|
214
|
+
See [PUBLISHING.md](PUBLISHING.md) for instructions on:
|
|
215
|
+
- Publishing to PyPI
|
|
216
|
+
- Publishing to GitHub
|
|
217
|
+
- Version management
|
|
218
|
+
- Release workflow
|
|
219
|
+
|
|
220
|
+
## Development
|
|
221
|
+
|
|
222
|
+
The server uses:
|
|
223
|
+
- **mcp**: Model Context Protocol SDK
|
|
224
|
+
- **requests**: HTTP client for backend API calls
|
|
225
|
+
- **subprocess**: Git command execution with SSH key management
|
|
226
|
+
|
|
227
|
+
### Local Development
|
|
228
|
+
|
|
229
|
+
```bash
|
|
230
|
+
# Clone the repository
|
|
231
|
+
git clone https://github.com/yourusername/ai-coding-gym-mcp.git
|
|
232
|
+
cd ai-coding-gym-mcp
|
|
233
|
+
|
|
234
|
+
# Install in development mode
|
|
235
|
+
pip install -e .
|
|
236
|
+
|
|
237
|
+
# Run tests (if available)
|
|
238
|
+
pytest
|
|
239
|
+
|
|
240
|
+
# Test the server locally
|
|
241
|
+
python server.py
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
## Contributing
|
|
245
|
+
|
|
246
|
+
Contributions welcome! Please:
|
|
247
|
+
1. Fork the repository
|
|
248
|
+
2. Create a feature branch
|
|
249
|
+
3. Make your changes
|
|
250
|
+
4. Submit a pull request
|
|
251
|
+
|
|
252
|
+
## License
|
|
253
|
+
|
|
254
|
+
MIT
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
server.py,sha256=4MZVe8BZFE-qSua-fzBD8hHNwEur7nPqklcmfIwR15I,18691
|
|
2
|
+
ai_coding_gym_mcp-0.1.0.dist-info/METADATA,sha256=bLTu2JVY10OYjrfPl3EzuZ06nvxVGhdIytgOexsap20,6377
|
|
3
|
+
ai_coding_gym_mcp-0.1.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
4
|
+
ai_coding_gym_mcp-0.1.0.dist-info/entry_points.txt,sha256=O_Ya91fge6V-bwpf4oace2eFB2asJVEE4Oe3fWcL6dY,50
|
|
5
|
+
ai_coding_gym_mcp-0.1.0.dist-info/top_level.txt,sha256=StKOSmRhvWS5IPcvhsDRbtxUTEofJgYFGOu5AAJdSWo,7
|
|
6
|
+
ai_coding_gym_mcp-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
server
|
server.py
ADDED
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
MCP Server for AI Coding Gym
|
|
4
|
+
Provides /fetch and /submit tools for local problem solving workflow
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import subprocess
|
|
11
|
+
import tempfile
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Dict, Optional
|
|
15
|
+
import requests
|
|
16
|
+
|
|
17
|
+
from mcp.server import Server
|
|
18
|
+
from mcp.types import Tool, TextContent, ErrorData
|
|
19
|
+
import mcp.server.stdio
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Global state
|
|
23
|
+
app = Server("ai-coding-gym")
|
|
24
|
+
credentials_store: Dict[str, Dict[str, Any]] = {} # problem_id -> {deploy_key, repo_url, branch, etc}
|
|
25
|
+
config_store: Dict[str, str] = {} # Global config: user_id, server_url, api_key
|
|
26
|
+
|
|
27
|
+
# Default server URL
|
|
28
|
+
DEFAULT_SERVER_URL = "http://ai-coding-gym.cis240515.projects.jetstream-cloud.org:4000"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Tool definitions
|
|
32
|
+
CONFIGURE_TOOL = Tool(
|
|
33
|
+
name="configure",
|
|
34
|
+
description="Configure global settings for the MCP server. Generates an SSH key pair locally, "
|
|
35
|
+
"sends the public key to the server, and receives your repository name. "
|
|
36
|
+
"Set your user_id, server_url, and optional api_key once. "
|
|
37
|
+
"These values will be used as defaults for all fetch and submit operations.",
|
|
38
|
+
inputSchema={
|
|
39
|
+
"type": "object",
|
|
40
|
+
"properties": {
|
|
41
|
+
"user_id": {
|
|
42
|
+
"type": "string",
|
|
43
|
+
"description": "Your user ID for authentication with the backend server"
|
|
44
|
+
},
|
|
45
|
+
"server_url": {
|
|
46
|
+
"type": "string",
|
|
47
|
+
"description": "Backend server URL (e.g., 'https://api.example.com')"
|
|
48
|
+
},
|
|
49
|
+
"api_key": {
|
|
50
|
+
"type": "string",
|
|
51
|
+
"description": "API key for backend authentication (optional)"
|
|
52
|
+
},
|
|
53
|
+
"workspace_dir": {
|
|
54
|
+
"type": "string",
|
|
55
|
+
"description": "Default workspace directory for cloning repositories (optional)"
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"required": ["user_id", "server_url"]
|
|
59
|
+
}
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
FETCH_TOOL = Tool(
|
|
63
|
+
name="fetch",
|
|
64
|
+
description="Fetch a coding problem from the server and clone the repository locally. "
|
|
65
|
+
"Uses your SSH key from /configure to access the repository.",
|
|
66
|
+
inputSchema={
|
|
67
|
+
"type": "object",
|
|
68
|
+
"properties": {
|
|
69
|
+
"problem_id": {
|
|
70
|
+
"type": "string",
|
|
71
|
+
"description": "The unique identifier for the problem (e.g., 'django__django-10097')"
|
|
72
|
+
},
|
|
73
|
+
"user_id": {
|
|
74
|
+
"type": "string",
|
|
75
|
+
"description": "Your user ID (optional if configured globally via /configure)"
|
|
76
|
+
},
|
|
77
|
+
"server_url": {
|
|
78
|
+
"type": "string",
|
|
79
|
+
"description": "Backend server URL (optional if configured globally)"
|
|
80
|
+
},
|
|
81
|
+
"workspace_dir": {
|
|
82
|
+
"type": "string",
|
|
83
|
+
"description": "Local directory to clone the repository into (optional)"
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
"required": ["problem_id"]
|
|
87
|
+
}
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
SUBMIT_TOOL = Tool(
|
|
91
|
+
name="submit",
|
|
92
|
+
description="Submit your solution by committing all changes and pushing to the remote repository. "
|
|
93
|
+
"Notifies the backend server that a submission has been made.",
|
|
94
|
+
inputSchema={
|
|
95
|
+
"type": "object",
|
|
96
|
+
"properties": {
|
|
97
|
+
"problem_id": {
|
|
98
|
+
"type": "string",
|
|
99
|
+
"description": "The unique identifier for the problem"
|
|
100
|
+
},
|
|
101
|
+
"user_id": {
|
|
102
|
+
"type": "string",
|
|
103
|
+
"description": "Your user ID (optional if configured globally via /configure)"
|
|
104
|
+
},
|
|
105
|
+
"server_url": {
|
|
106
|
+
"type": "string",
|
|
107
|
+
"description": "Backend server URL (optional if configured globally)"
|
|
108
|
+
},
|
|
109
|
+
"commit_message": {
|
|
110
|
+
"type": "string",
|
|
111
|
+
"description": "Custom commit message (optional)"
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
"required": ["problem_id"]
|
|
115
|
+
}
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# Helper functions for Git SSH key management
|
|
120
|
+
def infer_user_id_from_keys() -> Optional[str]:
|
|
121
|
+
"""
|
|
122
|
+
Infer user_id by scanning for SSH key files in ~/.mcp-keys/.
|
|
123
|
+
Returns the user_id if found, None otherwise.
|
|
124
|
+
"""
|
|
125
|
+
key_dir = Path.home() / ".mcp-keys"
|
|
126
|
+
if not key_dir.exists():
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
# Look for *_id_rsa files
|
|
130
|
+
key_files = list(key_dir.glob("*_id_rsa"))
|
|
131
|
+
if not key_files:
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
# Extract user_id from the first key file found
|
|
135
|
+
key_file = key_files[0]
|
|
136
|
+
user_id = key_file.stem.replace("_id_rsa", "")
|
|
137
|
+
return user_id
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def generate_ssh_key_pair(user_id: str) -> tuple[Path, str]:
|
|
141
|
+
"""
|
|
142
|
+
Generate an SSH key pair for the user.
|
|
143
|
+
Returns tuple of (private_key_path, public_key_content)
|
|
144
|
+
"""
|
|
145
|
+
key_dir = Path.home() / ".mcp-keys"
|
|
146
|
+
key_dir.mkdir(mode=0o700, exist_ok=True)
|
|
147
|
+
|
|
148
|
+
key_path = key_dir / f"{user_id}_id_rsa"
|
|
149
|
+
|
|
150
|
+
# Generate SSH key pair if it doesn't exist
|
|
151
|
+
if not key_path.exists():
|
|
152
|
+
result = subprocess.run(
|
|
153
|
+
["ssh-keygen", "-t", "rsa", "-b", "4096", "-f", str(key_path), "-N", "", "-C", f"mcp-{user_id}"],
|
|
154
|
+
capture_output=True,
|
|
155
|
+
text=True
|
|
156
|
+
)
|
|
157
|
+
if result.returncode != 0:
|
|
158
|
+
raise RuntimeError(f"Failed to generate SSH key: {result.stderr}")
|
|
159
|
+
|
|
160
|
+
# Read public key
|
|
161
|
+
pub_key_path = Path(str(key_path) + ".pub")
|
|
162
|
+
public_key = pub_key_path.read_text().strip()
|
|
163
|
+
|
|
164
|
+
return key_path, public_key
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def setup_deploy_key(problem_id: str, deploy_key: str) -> Path:
|
|
168
|
+
"""
|
|
169
|
+
Write deployment key to a temporary file with secure permissions.
|
|
170
|
+
Returns the path to the key file.
|
|
171
|
+
"""
|
|
172
|
+
key_dir = Path.home() / ".mcp-keys"
|
|
173
|
+
key_dir.mkdir(mode=0o700, exist_ok=True)
|
|
174
|
+
|
|
175
|
+
key_path = key_dir / f"{problem_id}_deploy_key"
|
|
176
|
+
key_path.write_text(deploy_key)
|
|
177
|
+
key_path.chmod(0o600)
|
|
178
|
+
|
|
179
|
+
return key_path
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def cleanup_deploy_key(problem_id: str) -> None:
|
|
183
|
+
"""Remove deployment key file for a problem."""
|
|
184
|
+
key_path = Path.home() / ".mcp-keys" / f"{problem_id}_deploy_key"
|
|
185
|
+
if key_path.exists():
|
|
186
|
+
key_path.unlink()
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def run_git_command(cmd: str, cwd: str, key_path: Optional[Path] = None) -> subprocess.CompletedProcess:
|
|
190
|
+
"""
|
|
191
|
+
Execute a git command with optional SSH key configuration.
|
|
192
|
+
"""
|
|
193
|
+
env = os.environ.copy()
|
|
194
|
+
if key_path:
|
|
195
|
+
env["GIT_SSH_COMMAND"] = f"ssh -i {key_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
|
|
196
|
+
|
|
197
|
+
result = subprocess.run(
|
|
198
|
+
cmd,
|
|
199
|
+
shell=True,
|
|
200
|
+
cwd=cwd,
|
|
201
|
+
capture_output=True,
|
|
202
|
+
text=True,
|
|
203
|
+
env=env
|
|
204
|
+
)
|
|
205
|
+
return result
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# Tool implementations
|
|
209
|
+
async def configure(
|
|
210
|
+
user_id: str,
|
|
211
|
+
server_url: str,
|
|
212
|
+
api_key: Optional[str] = None,
|
|
213
|
+
workspace_dir: Optional[str] = None
|
|
214
|
+
) -> str:
|
|
215
|
+
"""Configure global settings for the MCP server."""
|
|
216
|
+
try:
|
|
217
|
+
# Generate SSH key pair
|
|
218
|
+
private_key_path, public_key = generate_ssh_key_pair(user_id)
|
|
219
|
+
|
|
220
|
+
# Register public key with server
|
|
221
|
+
api_endpoint = f"{server_url}/api/configure"
|
|
222
|
+
payload = {
|
|
223
|
+
"user_id": user_id,
|
|
224
|
+
"public_key": public_key
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if api_key:
|
|
228
|
+
payload["api_key"] = api_key
|
|
229
|
+
|
|
230
|
+
response = requests.post(api_endpoint, json=payload, timeout=30)
|
|
231
|
+
response.raise_for_status()
|
|
232
|
+
data = response.json()
|
|
233
|
+
|
|
234
|
+
repo_name = data.get("repo_name")
|
|
235
|
+
if not repo_name:
|
|
236
|
+
return "Error: Server did not return a repository name"
|
|
237
|
+
|
|
238
|
+
# Store configuration
|
|
239
|
+
config_store["user_id"] = user_id
|
|
240
|
+
config_store["server_url"] = server_url
|
|
241
|
+
config_store["repo_name"] = repo_name
|
|
242
|
+
config_store["private_key_path"] = str(private_key_path)
|
|
243
|
+
|
|
244
|
+
if api_key:
|
|
245
|
+
config_store["api_key"] = api_key
|
|
246
|
+
|
|
247
|
+
if workspace_dir:
|
|
248
|
+
config_store["workspace_dir"] = workspace_dir
|
|
249
|
+
|
|
250
|
+
return f"""Configuration saved successfully!
|
|
251
|
+
|
|
252
|
+
User ID: {user_id}
|
|
253
|
+
Server URL: {server_url}
|
|
254
|
+
Repository: {repo_name}
|
|
255
|
+
API Key: {'Set' if api_key else 'Not set'}
|
|
256
|
+
Workspace: {workspace_dir or 'Default (./workspace)'}
|
|
257
|
+
SSH Key: {private_key_path}
|
|
258
|
+
|
|
259
|
+
Your public key has been registered with the server.
|
|
260
|
+
You can now use /fetch and /submit without passing these parameters.
|
|
261
|
+
"""
|
|
262
|
+
|
|
263
|
+
except requests.RequestException as e:
|
|
264
|
+
return f"Error: Failed to register with server: {str(e)}"
|
|
265
|
+
except Exception as e:
|
|
266
|
+
return f"Error: Configuration failed: {str(e)}"
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
async def fetch_problem(
|
|
270
|
+
problem_id: str,
|
|
271
|
+
user_id: Optional[str] = None,
|
|
272
|
+
server_url: Optional[str] = None,
|
|
273
|
+
workspace_dir: Optional[str] = None
|
|
274
|
+
) -> str:
|
|
275
|
+
"""
|
|
276
|
+
Fetch problem by cloning only the specific branch from the forked repository.
|
|
277
|
+
"""
|
|
278
|
+
try:
|
|
279
|
+
# Use configured values if not provided
|
|
280
|
+
user_id = user_id or config_store.get("user_id") or infer_user_id_from_keys()
|
|
281
|
+
server_url = server_url or config_store.get("server_url") or DEFAULT_SERVER_URL
|
|
282
|
+
workspace_dir = workspace_dir or config_store.get("workspace_dir", "./workspace")
|
|
283
|
+
repo_name = config_store.get("repo_name")
|
|
284
|
+
|
|
285
|
+
if not user_id:
|
|
286
|
+
return "Error: Could not determine user_id. Please run /configure first to set up your credentials."
|
|
287
|
+
|
|
288
|
+
if not repo_name:
|
|
289
|
+
return "Error: Repository not configured. Please run /configure first."
|
|
290
|
+
|
|
291
|
+
# Get user's private key path
|
|
292
|
+
private_key_path = config_store.get("private_key_path")
|
|
293
|
+
if not private_key_path:
|
|
294
|
+
# Try to infer from user_id
|
|
295
|
+
key_path = Path.home() / ".mcp-keys" / f"{user_id}_id_rsa"
|
|
296
|
+
if not key_path.exists():
|
|
297
|
+
return "Error: SSH key not found. Please run /configure first."
|
|
298
|
+
private_key_path = str(key_path)
|
|
299
|
+
|
|
300
|
+
# Construct repo URL from config
|
|
301
|
+
# Assuming GITHUB_OWNER from server is "AICodingGym" or can be inferred
|
|
302
|
+
github_owner = os.environ.get("GITHUB_OWNER", "AICodingGym")
|
|
303
|
+
repo_url = f"git@github.com:{github_owner}/{repo_name}.git"
|
|
304
|
+
|
|
305
|
+
# The branch name is the problem_id itself in the forked repo
|
|
306
|
+
branch = problem_id
|
|
307
|
+
|
|
308
|
+
# Store credentials for later use
|
|
309
|
+
credentials_store[problem_id] = {
|
|
310
|
+
"repo_url": repo_url,
|
|
311
|
+
"branch": branch,
|
|
312
|
+
"user_id": user_id,
|
|
313
|
+
"server_url": server_url,
|
|
314
|
+
"private_key_path": private_key_path
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
# Use user's own SSH key
|
|
318
|
+
key_path = Path(private_key_path)
|
|
319
|
+
|
|
320
|
+
# Create workspace directory
|
|
321
|
+
workspace_path = Path(workspace_dir).resolve()
|
|
322
|
+
workspace_path.mkdir(parents=True, exist_ok=True)
|
|
323
|
+
|
|
324
|
+
problem_dir = workspace_path / problem_id
|
|
325
|
+
if problem_dir.exists():
|
|
326
|
+
# Check if it's already on the right branch
|
|
327
|
+
check_branch = f"cd {problem_dir} && git rev-parse --abbrev-ref HEAD"
|
|
328
|
+
result = subprocess.run(check_branch, shell=True, capture_output=True, text=True)
|
|
329
|
+
if result.returncode == 0 and result.stdout.strip() == branch:
|
|
330
|
+
# Pull latest changes
|
|
331
|
+
pull_cmd = f"git pull origin {branch}"
|
|
332
|
+
result = run_git_command(pull_cmd, str(problem_dir), key_path)
|
|
333
|
+
if result.returncode != 0:
|
|
334
|
+
return f"Error: Git pull failed:\n{result.stderr}"
|
|
335
|
+
return f"""Problem {problem_id} already exists. Updated to latest version.
|
|
336
|
+
|
|
337
|
+
Repository: {problem_dir}
|
|
338
|
+
Branch: {branch}
|
|
339
|
+
|
|
340
|
+
You can continue working on your solution!
|
|
341
|
+
"""
|
|
342
|
+
else:
|
|
343
|
+
return f"Error: Directory {problem_dir} already exists with different content. Please remove it first or use a different workspace."
|
|
344
|
+
|
|
345
|
+
# Clone only the specific branch (shallow clone for efficiency)
|
|
346
|
+
clone_cmd = f"git clone --single-branch --branch {branch} --depth 1 {repo_url} {problem_id}"
|
|
347
|
+
result = run_git_command(clone_cmd, str(workspace_path), key_path)
|
|
348
|
+
|
|
349
|
+
if result.returncode != 0:
|
|
350
|
+
return f"Error: Git clone failed:\n{result.stderr}\n\nMake sure the branch '{branch}' exists in the repository."
|
|
351
|
+
|
|
352
|
+
return f"""Successfully fetched problem: {problem_id}
|
|
353
|
+
|
|
354
|
+
Repository cloned to: {problem_dir}
|
|
355
|
+
Branch: {branch}
|
|
356
|
+
|
|
357
|
+
The repository contains only the problem branch (shallow clone to save space).
|
|
358
|
+
You can now start working on the solution!
|
|
359
|
+
"""
|
|
360
|
+
|
|
361
|
+
except Exception as e:
|
|
362
|
+
return f"Error: Unexpected error: {str(e)}"
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
async def submit_solution(
|
|
366
|
+
problem_id: str,
|
|
367
|
+
user_id: Optional[str] = None,
|
|
368
|
+
server_url: Optional[str] = None,
|
|
369
|
+
commit_message: Optional[str] = None
|
|
370
|
+
) -> str:
|
|
371
|
+
"""
|
|
372
|
+
Submit solution by committing and pushing changes, then notify backend.
|
|
373
|
+
"""
|
|
374
|
+
try:
|
|
375
|
+
# Use configured values if not provided
|
|
376
|
+
user_id = user_id or config_store.get("user_id") or infer_user_id_from_keys()
|
|
377
|
+
server_url = server_url or config_store.get("server_url") or DEFAULT_SERVER_URL
|
|
378
|
+
commit_message = commit_message or ""
|
|
379
|
+
|
|
380
|
+
if not user_id:
|
|
381
|
+
return "Error: Could not determine user_id. Please run /configure first to set up your credentials."
|
|
382
|
+
|
|
383
|
+
if not server_url:
|
|
384
|
+
return "Error: server_url is required. Please run /configure first or pass it as a parameter."
|
|
385
|
+
|
|
386
|
+
# Check if we have cached credentials
|
|
387
|
+
if problem_id not in credentials_store:
|
|
388
|
+
return f"Error: No credentials found for {problem_id}. Please run /fetch first."
|
|
389
|
+
|
|
390
|
+
creds = credentials_store[problem_id]
|
|
391
|
+
|
|
392
|
+
# Verify user_id matches
|
|
393
|
+
if creds["user_id"] != user_id:
|
|
394
|
+
return f"Error: User ID mismatch. Problem was fetched by {creds['user_id']}, not {user_id}."
|
|
395
|
+
|
|
396
|
+
# Find problem directory
|
|
397
|
+
workspace_dir = Path("./workspace").resolve()
|
|
398
|
+
problem_dir = workspace_dir / problem_id
|
|
399
|
+
|
|
400
|
+
if not problem_dir.exists():
|
|
401
|
+
return f"Error: Problem directory not found at {problem_dir}"
|
|
402
|
+
|
|
403
|
+
# Use user's SSH key
|
|
404
|
+
private_key_path = creds.get("private_key_path") or config_store.get("private_key_path")
|
|
405
|
+
if not private_key_path:
|
|
406
|
+
return "Error: SSH key path not found. Please run /fetch again."
|
|
407
|
+
|
|
408
|
+
key_path = Path(private_key_path)
|
|
409
|
+
|
|
410
|
+
# Stage all changes
|
|
411
|
+
result = run_git_command("git add -A", str(problem_dir))
|
|
412
|
+
if result.returncode != 0:
|
|
413
|
+
return f"Error: Git add failed:\n{result.stderr}"
|
|
414
|
+
|
|
415
|
+
# Check if there are changes to commit
|
|
416
|
+
status_result = run_git_command("git status --porcelain", str(problem_dir))
|
|
417
|
+
if not status_result.stdout.strip():
|
|
418
|
+
return "Warning: No changes to commit. Your working directory is clean."
|
|
419
|
+
|
|
420
|
+
# Commit changes
|
|
421
|
+
if not commit_message:
|
|
422
|
+
commit_message = f"Solution submission for {problem_id} at {datetime.now().isoformat()}"
|
|
423
|
+
|
|
424
|
+
commit_cmd = f'git commit -m "{commit_message}"'
|
|
425
|
+
result = run_git_command(commit_cmd, str(problem_dir))
|
|
426
|
+
if result.returncode != 0:
|
|
427
|
+
return f"Error: Git commit failed:\n{result.stderr}"
|
|
428
|
+
|
|
429
|
+
# Get commit hash
|
|
430
|
+
hash_result = run_git_command("git rev-parse HEAD", str(problem_dir))
|
|
431
|
+
commit_hash = hash_result.stdout.strip()
|
|
432
|
+
|
|
433
|
+
# Push to remote
|
|
434
|
+
branch = creds["branch"]
|
|
435
|
+
push_cmd = f"git push origin {branch}"
|
|
436
|
+
result = run_git_command(push_cmd, str(problem_dir), key_path)
|
|
437
|
+
|
|
438
|
+
if result.returncode != 0:
|
|
439
|
+
return f"Error: Git push failed:\n{result.stderr}"
|
|
440
|
+
|
|
441
|
+
# Notify backend server
|
|
442
|
+
api_endpoint = f"{server_url}/api/submit"
|
|
443
|
+
payload = {
|
|
444
|
+
"problem_id": problem_id,
|
|
445
|
+
"user_id": user_id,
|
|
446
|
+
"commit_hash": commit_hash,
|
|
447
|
+
"branch": branch,
|
|
448
|
+
"timestamp": datetime.now().isoformat()
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
# Add API key if configured
|
|
452
|
+
if config_store.get("api_key"):
|
|
453
|
+
payload["api_key"] = config_store["api_key"]
|
|
454
|
+
|
|
455
|
+
response = requests.post(api_endpoint, json=payload, timeout=30)
|
|
456
|
+
response.raise_for_status()
|
|
457
|
+
|
|
458
|
+
return f"""Successfully submitted solution for {problem_id}
|
|
459
|
+
|
|
460
|
+
Commit: {commit_hash[:8]}
|
|
461
|
+
Branch: {branch}
|
|
462
|
+
Pushed to remote
|
|
463
|
+
Backend notified
|
|
464
|
+
|
|
465
|
+
Your solution has been submitted for evaluation!
|
|
466
|
+
"""
|
|
467
|
+
|
|
468
|
+
except requests.RequestException as e:
|
|
469
|
+
return f"Warning: Changes committed and pushed, but failed to notify backend:\n{str(e)}"
|
|
470
|
+
except Exception as e:
|
|
471
|
+
return f"Error: Unexpected error: {str(e)}"
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
# MCP Server handlers
|
|
475
|
+
@app.list_tools()
|
|
476
|
+
async def list_tools() -> list[Tool]:
|
|
477
|
+
"""List available tools."""
|
|
478
|
+
return [CONFIGURE_TOOL, FETCH_TOOL, SUBMIT_TOOL]
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
@app.call_tool()
|
|
482
|
+
async def call_tool(name: str, arguments: Any) -> list[TextContent]:
|
|
483
|
+
"""Handle tool calls."""
|
|
484
|
+
try:
|
|
485
|
+
if name == "configure":
|
|
486
|
+
result = await configure(
|
|
487
|
+
user_id=arguments["user_id"],
|
|
488
|
+
server_url=arguments["server_url"],
|
|
489
|
+
api_key=arguments.get("api_key"),
|
|
490
|
+
workspace_dir=arguments.get("workspace_dir")
|
|
491
|
+
)
|
|
492
|
+
return [TextContent(type="text", text=result)]
|
|
493
|
+
|
|
494
|
+
elif name == "fetch":
|
|
495
|
+
result = await fetch_problem(
|
|
496
|
+
problem_id=arguments["problem_id"],
|
|
497
|
+
user_id=arguments.get("user_id"),
|
|
498
|
+
server_url=arguments.get("server_url"),
|
|
499
|
+
workspace_dir=arguments.get("workspace_dir")
|
|
500
|
+
)
|
|
501
|
+
return [TextContent(type="text", text=result)]
|
|
502
|
+
|
|
503
|
+
elif name == "submit":
|
|
504
|
+
result = await submit_solution(
|
|
505
|
+
problem_id=arguments["problem_id"],
|
|
506
|
+
user_id=arguments.get("user_id"),
|
|
507
|
+
server_url=arguments.get("server_url"),
|
|
508
|
+
commit_message=arguments.get("commit_message")
|
|
509
|
+
)
|
|
510
|
+
return [TextContent(type="text", text=result)]
|
|
511
|
+
|
|
512
|
+
else:
|
|
513
|
+
raise ValueError(f"Unknown tool: {name}")
|
|
514
|
+
|
|
515
|
+
except KeyError as e:
|
|
516
|
+
return [TextContent(type="text", text=f"Error: Missing required parameter: {str(e)}")]
|
|
517
|
+
except Exception as e:
|
|
518
|
+
return [TextContent(type="text", text=f"Error: Error executing tool: {str(e)}")]
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
async def main():
|
|
522
|
+
"""Run the MCP server."""
|
|
523
|
+
async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
|
|
524
|
+
await app.run(
|
|
525
|
+
read_stream,
|
|
526
|
+
write_stream,
|
|
527
|
+
app.create_initialization_options()
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
if __name__ == "__main__":
|
|
532
|
+
asyncio.run(main())
|