polygon-mcp-server 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.
- polygon_mcp/__init__.py +5 -0
- polygon_mcp/__main__.py +9 -0
- polygon_mcp/server.py +1030 -0
- polygon_mcp_server-0.1.0.dist-info/METADATA +81 -0
- polygon_mcp_server-0.1.0.dist-info/RECORD +8 -0
- polygon_mcp_server-0.1.0.dist-info/WHEEL +5 -0
- polygon_mcp_server-0.1.0.dist-info/entry_points.txt +2 -0
- polygon_mcp_server-0.1.0.dist-info/top_level.txt +1 -0
polygon_mcp/__init__.py
ADDED
polygon_mcp/__main__.py
ADDED
polygon_mcp/server.py
ADDED
|
@@ -0,0 +1,1030 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import io
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import logging
|
|
6
|
+
from logging.handlers import RotatingFileHandler
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
import patch_ng
|
|
11
|
+
from fastmcp import FastMCP
|
|
12
|
+
from polygon_api import (
|
|
13
|
+
FeedbackPolicy,
|
|
14
|
+
FileType,
|
|
15
|
+
PointsPolicy,
|
|
16
|
+
Polygon,
|
|
17
|
+
PolygonRequestFailedException,
|
|
18
|
+
ProblemInfo,
|
|
19
|
+
ResourceAdvancedProperties,
|
|
20
|
+
Statement,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
from polygon_api import HTTPRequestFailedException
|
|
25
|
+
except ImportError: # pragma: no cover - older exports
|
|
26
|
+
from polygon_api.api import HTTPRequestFailedException
|
|
27
|
+
|
|
28
|
+
DEFAULT_API_URL = "https://polygon.codeforces.com/api/"
|
|
29
|
+
DEFAULT_CONFIG_PATH = os.path.join(
|
|
30
|
+
os.getenv("XDG_CONFIG_HOME", os.path.join(os.path.expanduser("~"), ".config")),
|
|
31
|
+
"polygon-mcp",
|
|
32
|
+
"config.json",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
mcp = FastMCP("polygon")
|
|
36
|
+
|
|
37
|
+
_LOGGER = logging.getLogger("polygon_mcp")
|
|
38
|
+
if not _LOGGER.handlers:
|
|
39
|
+
_formatter = logging.Formatter("%(asctime)s %(levelname)s %(message)s")
|
|
40
|
+
log_path = os.getenv("POLYGON_MCP_LOG_FILE")
|
|
41
|
+
if not log_path:
|
|
42
|
+
state_home = os.getenv("XDG_STATE_HOME") or os.path.join(
|
|
43
|
+
os.path.expanduser("~"), ".local", "state"
|
|
44
|
+
)
|
|
45
|
+
log_path = os.path.join(state_home, "polygon-mcp", "polygon-mcp.log")
|
|
46
|
+
log_dir = os.path.dirname(log_path)
|
|
47
|
+
if log_dir:
|
|
48
|
+
os.makedirs(log_dir, exist_ok=True)
|
|
49
|
+
try:
|
|
50
|
+
with open(log_path, "a", encoding="utf-8"):
|
|
51
|
+
pass
|
|
52
|
+
os.chmod(log_path, 0o600)
|
|
53
|
+
except OSError:
|
|
54
|
+
pass
|
|
55
|
+
_handler = RotatingFileHandler(log_path, maxBytes=5 * 1024 * 1024, backupCount=5)
|
|
56
|
+
_handler.setFormatter(_formatter)
|
|
57
|
+
_LOGGER.addHandler(_handler)
|
|
58
|
+
_LOGGER.setLevel(logging.INFO)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _to_jsonable(value: Any) -> Any:
|
|
62
|
+
if value is None:
|
|
63
|
+
return None
|
|
64
|
+
if isinstance(value, (str, int, float, bool)):
|
|
65
|
+
return value
|
|
66
|
+
if isinstance(value, Enum):
|
|
67
|
+
return str(value)
|
|
68
|
+
if isinstance(value, dict):
|
|
69
|
+
return {str(k): _to_jsonable(v) for k, v in value.items()}
|
|
70
|
+
if isinstance(value, (list, tuple, set)):
|
|
71
|
+
return [_to_jsonable(v) for v in value]
|
|
72
|
+
if hasattr(value, "__dict__"):
|
|
73
|
+
data = {}
|
|
74
|
+
for key, item in vars(value).items():
|
|
75
|
+
if key.startswith("_"):
|
|
76
|
+
continue
|
|
77
|
+
data[key] = _to_jsonable(item)
|
|
78
|
+
return data
|
|
79
|
+
return str(value)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _load_config(path: str) -> dict:
|
|
83
|
+
if not path or not os.path.exists(path):
|
|
84
|
+
return {}
|
|
85
|
+
try:
|
|
86
|
+
with open(path, "r", encoding="utf-8") as handle:
|
|
87
|
+
data = json.load(handle)
|
|
88
|
+
except (OSError, json.JSONDecodeError):
|
|
89
|
+
return {}
|
|
90
|
+
parsed = data
|
|
91
|
+
return parsed if isinstance(parsed, dict) else {}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _resolve_config_path() -> str:
|
|
95
|
+
return os.getenv("POLYGON_MCP_CONFIG") or DEFAULT_CONFIG_PATH
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _write_config(path: str, payload: dict) -> None:
|
|
99
|
+
if not path:
|
|
100
|
+
raise ValueError("config_path is empty")
|
|
101
|
+
directory = os.path.dirname(path)
|
|
102
|
+
if directory:
|
|
103
|
+
os.makedirs(directory, exist_ok=True)
|
|
104
|
+
tmp_path = f"{path}.tmp"
|
|
105
|
+
with open(tmp_path, "w", encoding="utf-8") as handle:
|
|
106
|
+
json.dump(payload, handle, indent=2, sort_keys=True)
|
|
107
|
+
handle.write("\n")
|
|
108
|
+
os.replace(tmp_path, path)
|
|
109
|
+
try:
|
|
110
|
+
os.chmod(path, 0o600)
|
|
111
|
+
except OSError:
|
|
112
|
+
pass
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _save_config(path: str, updates: dict) -> dict:
|
|
116
|
+
existing = _load_config(path)
|
|
117
|
+
merged = dict(existing)
|
|
118
|
+
for key, value in updates.items():
|
|
119
|
+
if value is None:
|
|
120
|
+
continue
|
|
121
|
+
merged[key] = value
|
|
122
|
+
_write_config(path, merged)
|
|
123
|
+
return merged
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _resolve_config() -> tuple[str, str, str]:
|
|
127
|
+
config_path = _resolve_config_path()
|
|
128
|
+
stored = _load_config(config_path)
|
|
129
|
+
|
|
130
|
+
api_url = os.getenv("POLYGON_API_URL") or stored.get("api_url") or DEFAULT_API_URL
|
|
131
|
+
api_key = os.getenv("POLYGON_API_KEY") or stored.get("api_key")
|
|
132
|
+
api_secret = os.getenv("POLYGON_API_SECRET") or stored.get("api_secret")
|
|
133
|
+
|
|
134
|
+
if not api_key or not api_secret:
|
|
135
|
+
raise ValueError("Missing credentials: set POLYGON_API_KEY and POLYGON_API_SECRET")
|
|
136
|
+
|
|
137
|
+
return api_url, api_key, api_secret
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
_polygon_client: Optional[Polygon] = None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _get_client() -> Polygon:
|
|
144
|
+
global _polygon_client
|
|
145
|
+
if _polygon_client is None:
|
|
146
|
+
api_url, api_key, api_secret = _resolve_config()
|
|
147
|
+
_polygon_client = Polygon(api_url, api_key, api_secret)
|
|
148
|
+
return _polygon_client
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@mcp.tool()
|
|
152
|
+
def configure_polygon_credentials(
|
|
153
|
+
api_key: str,
|
|
154
|
+
api_secret: str,
|
|
155
|
+
api_url: Optional[str] = None,
|
|
156
|
+
) -> Any:
|
|
157
|
+
"""Store Polygon API credentials in the MCP config file."""
|
|
158
|
+
key = api_key.strip()
|
|
159
|
+
secret = api_secret.strip()
|
|
160
|
+
if not key:
|
|
161
|
+
raise ValueError("api_key is empty")
|
|
162
|
+
if not secret:
|
|
163
|
+
raise ValueError("api_secret is empty")
|
|
164
|
+
url = api_url.strip() if api_url is not None else None
|
|
165
|
+
config_path = _resolve_config_path()
|
|
166
|
+
stored = _save_config(
|
|
167
|
+
config_path,
|
|
168
|
+
{"api_key": key, "api_secret": secret, "api_url": url},
|
|
169
|
+
)
|
|
170
|
+
global _polygon_client
|
|
171
|
+
_polygon_client = None
|
|
172
|
+
return {
|
|
173
|
+
"config_path": config_path,
|
|
174
|
+
"api_url": stored.get("api_url") or DEFAULT_API_URL,
|
|
175
|
+
"stored": {"api_key": True, "api_secret": True, "api_url": url is not None},
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _call_polygon(fn, *args, **kwargs):
|
|
180
|
+
try:
|
|
181
|
+
return fn(*args, **kwargs)
|
|
182
|
+
except (PolygonRequestFailedException, HTTPRequestFailedException) as exc:
|
|
183
|
+
message = getattr(exc, "comment", None) or str(exc)
|
|
184
|
+
raise RuntimeError(f"Polygon API error: {message}") from exc
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _parse_file_type(value: Optional[str]):
|
|
188
|
+
if value is None:
|
|
189
|
+
return None
|
|
190
|
+
if isinstance(value, FileType):
|
|
191
|
+
return value
|
|
192
|
+
normalized = str(value).strip().lower()
|
|
193
|
+
for file_type in FileType:
|
|
194
|
+
if normalized in (str(file_type).lower(), file_type.name.lower()):
|
|
195
|
+
return file_type
|
|
196
|
+
raise ValueError(f"Unknown file type: {value}")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _parse_enum(enum_cls, value, *, allow_none: bool = True):
|
|
200
|
+
if value is None:
|
|
201
|
+
if allow_none:
|
|
202
|
+
return None
|
|
203
|
+
raise ValueError(f"Missing value for {enum_cls.__name__}")
|
|
204
|
+
if isinstance(value, enum_cls):
|
|
205
|
+
return value
|
|
206
|
+
normalized = str(value).strip().upper()
|
|
207
|
+
for item in enum_cls:
|
|
208
|
+
if normalized == item.name.upper():
|
|
209
|
+
return item
|
|
210
|
+
raise ValueError(f"Unknown {enum_cls.__name__}: {value}")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _decode_content(content: str, content_base64: bool) -> Any:
|
|
214
|
+
if content_base64:
|
|
215
|
+
return base64.b64decode(content)
|
|
216
|
+
return content
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _read_local_file(path: str) -> bytes:
|
|
220
|
+
if not path:
|
|
221
|
+
raise ValueError("local_path is empty")
|
|
222
|
+
with open(path, "rb") as handle:
|
|
223
|
+
return handle.read()
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _resolve_output_path(path: str) -> str:
|
|
227
|
+
if not path:
|
|
228
|
+
raise ValueError("output_path is empty")
|
|
229
|
+
abs_path = os.path.abspath(path)
|
|
230
|
+
allowed_roots = [os.getcwd(), "/tmp"]
|
|
231
|
+
extra_roots = os.getenv("POLYGON_MCP_OUTPUT_ROOTS")
|
|
232
|
+
if extra_roots:
|
|
233
|
+
allowed_roots.extend([os.path.abspath(p) for p in extra_roots.split(os.pathsep) if p])
|
|
234
|
+
if not any(
|
|
235
|
+
abs_path == root or abs_path.startswith(root.rstrip(os.sep) + os.sep)
|
|
236
|
+
for root in allowed_roots
|
|
237
|
+
):
|
|
238
|
+
raise ValueError(
|
|
239
|
+
"output_path must be within the project directory, /tmp, or POLYGON_MCP_OUTPUT_ROOTS"
|
|
240
|
+
)
|
|
241
|
+
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
|
|
242
|
+
return abs_path
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _slice_lines(text: str, start_line: Optional[int], line_count: Optional[int]) -> str:
|
|
246
|
+
if start_line is None and line_count is None:
|
|
247
|
+
return text
|
|
248
|
+
if start_line is None:
|
|
249
|
+
start_line = 1
|
|
250
|
+
if start_line < 1:
|
|
251
|
+
raise ValueError("start_line must be >= 1")
|
|
252
|
+
if line_count is not None and line_count < 0:
|
|
253
|
+
raise ValueError("line_count must be >= 0")
|
|
254
|
+
lines = text.splitlines(keepends=True)
|
|
255
|
+
start_index = start_line - 1
|
|
256
|
+
end_index = None if line_count is None else start_index + line_count
|
|
257
|
+
return "".join(lines[start_index:end_index])
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _apply_line_edit(text: str, start_line: int, line_count: int, replacement: str) -> str:
|
|
261
|
+
if start_line < 1:
|
|
262
|
+
raise ValueError("start_line must be >= 1")
|
|
263
|
+
if line_count < 0:
|
|
264
|
+
raise ValueError("line_count must be >= 0")
|
|
265
|
+
lines = text.splitlines(keepends=True)
|
|
266
|
+
start_index = start_line - 1
|
|
267
|
+
if start_index > len(lines):
|
|
268
|
+
raise ValueError("start_line is beyond end of file")
|
|
269
|
+
end_index = start_index + line_count
|
|
270
|
+
if end_index > len(lines):
|
|
271
|
+
raise ValueError("line_count goes beyond end of file")
|
|
272
|
+
new_lines = lines[:start_index] + [replacement] + lines[end_index:]
|
|
273
|
+
return "".join(new_lines)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _parse_unified_diff(patch_text: str) -> list[dict]:
|
|
277
|
+
lines = patch_text.splitlines(keepends=True)
|
|
278
|
+
hunks: list[dict] = []
|
|
279
|
+
i = 0
|
|
280
|
+
file_headers_seen = 0
|
|
281
|
+
|
|
282
|
+
while i < len(lines):
|
|
283
|
+
line = lines[i]
|
|
284
|
+
if line.startswith("--- "):
|
|
285
|
+
file_headers_seen += 1
|
|
286
|
+
if file_headers_seen > 1:
|
|
287
|
+
raise ValueError("Only single-file patches are supported")
|
|
288
|
+
i += 1
|
|
289
|
+
if i >= len(lines) or not lines[i].startswith("+++ "):
|
|
290
|
+
raise ValueError("Invalid patch: missing '+++' header")
|
|
291
|
+
i += 1
|
|
292
|
+
continue
|
|
293
|
+
if line.startswith("@@ "):
|
|
294
|
+
header = line
|
|
295
|
+
i += 1
|
|
296
|
+
# @@ -l,s +l,s @@
|
|
297
|
+
try:
|
|
298
|
+
meta = header.strip().split()
|
|
299
|
+
old_part = meta[1] # -l,s
|
|
300
|
+
new_part = meta[2] # +l,s
|
|
301
|
+
old_nums = old_part[1:].split(",")
|
|
302
|
+
new_nums = new_part[1:].split(",")
|
|
303
|
+
start_old = int(old_nums[0])
|
|
304
|
+
len_old = int(old_nums[1]) if len(old_nums) > 1 else 1
|
|
305
|
+
start_new = int(new_nums[0])
|
|
306
|
+
len_new = int(new_nums[1]) if len(new_nums) > 1 else 1
|
|
307
|
+
except Exception as exc:
|
|
308
|
+
raise ValueError(f"Invalid hunk header: {header.strip()}") from exc
|
|
309
|
+
|
|
310
|
+
hunk_lines: list[str] = []
|
|
311
|
+
while i < len(lines):
|
|
312
|
+
next_line = lines[i]
|
|
313
|
+
if next_line.startswith("@@ "):
|
|
314
|
+
break
|
|
315
|
+
if next_line.startswith("--- ") or next_line.startswith("+++ "):
|
|
316
|
+
break
|
|
317
|
+
if not next_line.startswith((" ", "+", "-", "\\")):
|
|
318
|
+
raise ValueError(f"Invalid patch line: {next_line!r}")
|
|
319
|
+
hunk_lines.append(next_line)
|
|
320
|
+
i += 1
|
|
321
|
+
hunks.append(
|
|
322
|
+
{
|
|
323
|
+
"start_old": start_old,
|
|
324
|
+
"len_old": len_old,
|
|
325
|
+
"start_new": start_new,
|
|
326
|
+
"len_new": len_new,
|
|
327
|
+
"lines": hunk_lines,
|
|
328
|
+
}
|
|
329
|
+
)
|
|
330
|
+
continue
|
|
331
|
+
i += 1
|
|
332
|
+
|
|
333
|
+
if not hunks:
|
|
334
|
+
raise ValueError("Patch contains no hunks")
|
|
335
|
+
return hunks
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _apply_unified_diff(text: str, patch_text: str, expected_name: Optional[str] = None) -> str:
|
|
339
|
+
patch_bytes = patch_text.encode("utf-8")
|
|
340
|
+
if not patch_bytes.endswith(b"\n"):
|
|
341
|
+
patch_bytes += b"\n"
|
|
342
|
+
patch_set = patch_ng.fromstring(patch_bytes)
|
|
343
|
+
if not patch_set or patch_set.errors:
|
|
344
|
+
details = ""
|
|
345
|
+
if patch_set and getattr(patch_set, "errors", None):
|
|
346
|
+
details = f" (errors={patch_set.errors})"
|
|
347
|
+
raise ValueError(f"Invalid patch format{details}")
|
|
348
|
+
if len(patch_set.items) != 1:
|
|
349
|
+
raise ValueError("Only single-file patches are supported")
|
|
350
|
+
|
|
351
|
+
item = patch_set.items[0]
|
|
352
|
+
source = item.source.decode("utf-8", errors="ignore") if isinstance(item.source, (bytes, bytearray)) else str(item.source)
|
|
353
|
+
target = item.target.decode("utf-8", errors="ignore") if isinstance(item.target, (bytes, bytearray)) else str(item.target)
|
|
354
|
+
patch_name = os.path.basename(target or source)
|
|
355
|
+
if expected_name and patch_name and patch_name != expected_name:
|
|
356
|
+
raise ValueError(f"Patch file name '{patch_name}' does not match expected '{expected_name}'")
|
|
357
|
+
|
|
358
|
+
instream = io.BytesIO(text.encode("utf-8"))
|
|
359
|
+
patched_bytes = b"".join(patch_set.patch_stream(instream, item.hunks))
|
|
360
|
+
return patched_bytes.decode("utf-8")
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
_STATEMENT_SECTIONS = {
|
|
364
|
+
"legend",
|
|
365
|
+
"input",
|
|
366
|
+
"output",
|
|
367
|
+
"notes",
|
|
368
|
+
"tutorial",
|
|
369
|
+
"scoring",
|
|
370
|
+
"interaction",
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _normalize_statement_section(section: str) -> str:
|
|
375
|
+
normalized = section.strip().lower()
|
|
376
|
+
if normalized not in _STATEMENT_SECTIONS:
|
|
377
|
+
raise ValueError(
|
|
378
|
+
"Unknown statement section. Use one of: legend, input, output, notes, tutorial, scoring, interaction."
|
|
379
|
+
)
|
|
380
|
+
return normalized
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _resource_adv_from_dict(data: Optional[dict]) -> Optional[ResourceAdvancedProperties]:
|
|
384
|
+
if data is None:
|
|
385
|
+
return None
|
|
386
|
+
if data.get("delete") is True:
|
|
387
|
+
return ResourceAdvancedProperties.DELETE
|
|
388
|
+
return ResourceAdvancedProperties(
|
|
389
|
+
for_types=data.get("for_types"),
|
|
390
|
+
main=data.get("main"),
|
|
391
|
+
stages=data.get("stages"),
|
|
392
|
+
assets=data.get("assets"),
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
@mcp.tool()
|
|
397
|
+
def problems_list(
|
|
398
|
+
show_deleted: Optional[bool] = None,
|
|
399
|
+
id: Optional[int] = None,
|
|
400
|
+
name: Optional[str] = None,
|
|
401
|
+
owner: Optional[str] = None,
|
|
402
|
+
) -> Any:
|
|
403
|
+
polygon = _get_client()
|
|
404
|
+
result = _call_polygon(polygon.problems_list, show_deleted=show_deleted, id=id, name=name, owner=owner)
|
|
405
|
+
return _to_jsonable(result)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
@mcp.tool()
|
|
409
|
+
def problem_info(problem_id: int) -> Any:
|
|
410
|
+
polygon = _get_client()
|
|
411
|
+
result = _call_polygon(polygon.problem_info, problem_id)
|
|
412
|
+
return _to_jsonable(result)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
@mcp.tool()
|
|
416
|
+
def problem_create(name: str) -> Any:
|
|
417
|
+
polygon = _get_client()
|
|
418
|
+
result = _call_polygon(polygon.problem_create, name)
|
|
419
|
+
return _to_jsonable(result)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
@mcp.tool()
|
|
423
|
+
def problem_update_info(
|
|
424
|
+
problem_id: int,
|
|
425
|
+
input_file: Optional[str] = None,
|
|
426
|
+
output_file: Optional[str] = None,
|
|
427
|
+
interactive: Optional[bool] = None,
|
|
428
|
+
time_limit: Optional[float] = None,
|
|
429
|
+
memory_limit: Optional[int] = None,
|
|
430
|
+
) -> Any:
|
|
431
|
+
polygon = _get_client()
|
|
432
|
+
info = ProblemInfo(
|
|
433
|
+
input_file=input_file,
|
|
434
|
+
output_file=output_file,
|
|
435
|
+
interactive=interactive,
|
|
436
|
+
time_limit=time_limit,
|
|
437
|
+
memory_limit=memory_limit,
|
|
438
|
+
)
|
|
439
|
+
result = _call_polygon(polygon.problem_update_info, problem_id, info)
|
|
440
|
+
return _to_jsonable(result)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
@mcp.tool()
|
|
444
|
+
def problem_update_working_copy(problem_id: int) -> Any:
|
|
445
|
+
polygon = _get_client()
|
|
446
|
+
result = _call_polygon(polygon.problem_update_working_copy, problem_id)
|
|
447
|
+
return _to_jsonable(result)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
@mcp.tool()
|
|
451
|
+
def problem_discard_working_copy(problem_id: int) -> Any:
|
|
452
|
+
polygon = _get_client()
|
|
453
|
+
result = _call_polygon(polygon.problem_discard_working_copy, problem_id)
|
|
454
|
+
return _to_jsonable(result)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
@mcp.tool()
|
|
458
|
+
def problem_commit_changes(
|
|
459
|
+
problem_id: int,
|
|
460
|
+
minor_changes: Optional[bool] = None,
|
|
461
|
+
message: Optional[str] = None,
|
|
462
|
+
) -> Any:
|
|
463
|
+
polygon = _get_client()
|
|
464
|
+
result = _call_polygon(
|
|
465
|
+
polygon.problem_commit_changes,
|
|
466
|
+
problem_id,
|
|
467
|
+
minor_changes=minor_changes,
|
|
468
|
+
message=message,
|
|
469
|
+
)
|
|
470
|
+
return _to_jsonable(result)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
@mcp.tool()
|
|
474
|
+
def problem_statements(
|
|
475
|
+
problem_id: int,
|
|
476
|
+
lang: Optional[str] = None,
|
|
477
|
+
fields: Optional[list[str]] = None,
|
|
478
|
+
) -> Any:
|
|
479
|
+
"""Get problem statements, optionally selecting a language and fields.
|
|
480
|
+
|
|
481
|
+
fields can include: encoding, name, legend, input, output, scoring,
|
|
482
|
+
interaction, notes, tutorial.
|
|
483
|
+
"""
|
|
484
|
+
polygon = _get_client()
|
|
485
|
+
result = _call_polygon(polygon.problem_statements, problem_id)
|
|
486
|
+
data = _to_jsonable(result)
|
|
487
|
+
if not isinstance(data, dict):
|
|
488
|
+
return data
|
|
489
|
+
if lang is not None:
|
|
490
|
+
statement = data.get(lang)
|
|
491
|
+
if statement is None:
|
|
492
|
+
raise ValueError(f"Statement not found for lang: {lang}")
|
|
493
|
+
if fields is None:
|
|
494
|
+
return {lang: statement}
|
|
495
|
+
field_set = set(fields)
|
|
496
|
+
return {lang: {k: v for k, v in statement.items() if k in field_set}}
|
|
497
|
+
if fields is None:
|
|
498
|
+
return data
|
|
499
|
+
field_set = set(fields)
|
|
500
|
+
return {k: {sk: sv for sk, sv in v.items() if sk in field_set} for k, v in data.items()}
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
@mcp.tool()
|
|
504
|
+
def problem_save_statement(
|
|
505
|
+
problem_id: int,
|
|
506
|
+
lang: str,
|
|
507
|
+
encoding: Optional[str] = None,
|
|
508
|
+
name: Optional[str] = None,
|
|
509
|
+
legend: Optional[str] = None,
|
|
510
|
+
input: Optional[str] = None,
|
|
511
|
+
output: Optional[str] = None,
|
|
512
|
+
scoring: Optional[str] = None,
|
|
513
|
+
interaction: Optional[str] = None,
|
|
514
|
+
notes: Optional[str] = None,
|
|
515
|
+
tutorial: Optional[str] = None,
|
|
516
|
+
) -> Any:
|
|
517
|
+
"""Save or partially update a statement. Use None to leave fields unchanged."""
|
|
518
|
+
polygon = _get_client()
|
|
519
|
+
statement = Statement(
|
|
520
|
+
encoding=encoding,
|
|
521
|
+
name=name,
|
|
522
|
+
legend=legend,
|
|
523
|
+
input=input,
|
|
524
|
+
output=output,
|
|
525
|
+
scoring=scoring,
|
|
526
|
+
interaction=interaction,
|
|
527
|
+
notes=notes,
|
|
528
|
+
tutorial=tutorial,
|
|
529
|
+
)
|
|
530
|
+
result = _call_polygon(polygon.problem_save_statement, problem_id, lang, statement)
|
|
531
|
+
return _to_jsonable(result)
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
@mcp.tool()
|
|
535
|
+
def problem_statement_resources(problem_id: int) -> Any:
|
|
536
|
+
polygon = _get_client()
|
|
537
|
+
result = _call_polygon(polygon.problem_statement_resources, problem_id)
|
|
538
|
+
return _to_jsonable(result)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
@mcp.tool()
|
|
542
|
+
def problem_save_statement_resource(
|
|
543
|
+
problem_id: int,
|
|
544
|
+
name: str,
|
|
545
|
+
content: Optional[str] = None,
|
|
546
|
+
content_base64: bool = False,
|
|
547
|
+
local_path: Optional[str] = None,
|
|
548
|
+
check_existing: Optional[bool] = None,
|
|
549
|
+
) -> Any:
|
|
550
|
+
polygon = _get_client()
|
|
551
|
+
if local_path:
|
|
552
|
+
file_value = _read_local_file(local_path)
|
|
553
|
+
else:
|
|
554
|
+
if content is None:
|
|
555
|
+
raise ValueError("content or local_path is required")
|
|
556
|
+
file_value = _decode_content(content, content_base64)
|
|
557
|
+
result = _call_polygon(
|
|
558
|
+
polygon.problem_save_statement_resource,
|
|
559
|
+
problem_id,
|
|
560
|
+
name,
|
|
561
|
+
file_value,
|
|
562
|
+
check_existing=check_existing,
|
|
563
|
+
)
|
|
564
|
+
return _to_jsonable(result)
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
@mcp.tool()
|
|
568
|
+
def problem_checker(problem_id: int) -> Any:
|
|
569
|
+
polygon = _get_client()
|
|
570
|
+
result = _call_polygon(polygon.problem_checker, problem_id)
|
|
571
|
+
return _to_jsonable(result)
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
@mcp.tool()
|
|
575
|
+
def problem_validator(problem_id: int) -> Any:
|
|
576
|
+
polygon = _get_client()
|
|
577
|
+
result = _call_polygon(polygon.problem_validator, problem_id)
|
|
578
|
+
return _to_jsonable(result)
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
@mcp.tool()
|
|
582
|
+
def problem_interactor(problem_id: int) -> Any:
|
|
583
|
+
polygon = _get_client()
|
|
584
|
+
result = _call_polygon(polygon.problem_interactor, problem_id)
|
|
585
|
+
return _to_jsonable(result)
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
@mcp.tool()
|
|
589
|
+
def problem_set_validator(problem_id: int, validator: str) -> Any:
|
|
590
|
+
polygon = _get_client()
|
|
591
|
+
result = _call_polygon(polygon.problem_set_validator, problem_id, validator)
|
|
592
|
+
return _to_jsonable(result)
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
@mcp.tool()
|
|
596
|
+
def problem_set_checker(problem_id: int, checker: str) -> Any:
|
|
597
|
+
polygon = _get_client()
|
|
598
|
+
result = _call_polygon(polygon.problem_set_checker, problem_id, checker)
|
|
599
|
+
return _to_jsonable(result)
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
@mcp.tool()
|
|
603
|
+
def problem_set_interactor(problem_id: int, interactor: str) -> Any:
|
|
604
|
+
polygon = _get_client()
|
|
605
|
+
result = _call_polygon(polygon.problem_set_interactor, problem_id, interactor)
|
|
606
|
+
return _to_jsonable(result)
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
@mcp.tool()
|
|
610
|
+
def problem_files(problem_id: int) -> Any:
|
|
611
|
+
polygon = _get_client()
|
|
612
|
+
result = _call_polygon(polygon.problem_files, problem_id)
|
|
613
|
+
return _to_jsonable(result)
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
@mcp.tool()
|
|
617
|
+
def problem_view_file(
|
|
618
|
+
problem_id: int,
|
|
619
|
+
type: str,
|
|
620
|
+
name: str,
|
|
621
|
+
start_line: Optional[int] = None,
|
|
622
|
+
line_count: Optional[int] = None,
|
|
623
|
+
) -> Any:
|
|
624
|
+
polygon = _get_client()
|
|
625
|
+
file_type = _parse_file_type(type)
|
|
626
|
+
data = _call_polygon(polygon.problem_view_file, problem_id, file_type, name)
|
|
627
|
+
data = _slice_lines(data, start_line, line_count)
|
|
628
|
+
return {"data": data, "encoding": "utf-8"}
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
@mcp.tool()
|
|
632
|
+
def problem_save_file(
|
|
633
|
+
problem_id: int,
|
|
634
|
+
type: str,
|
|
635
|
+
name: str,
|
|
636
|
+
content: Optional[str] = None,
|
|
637
|
+
content_base64: bool = False,
|
|
638
|
+
local_path: Optional[str] = None,
|
|
639
|
+
source_type: Optional[str] = None,
|
|
640
|
+
resource_advanced_properties: Optional[dict] = None,
|
|
641
|
+
) -> Any:
|
|
642
|
+
polygon = _get_client()
|
|
643
|
+
file_type = _parse_file_type(type)
|
|
644
|
+
if local_path:
|
|
645
|
+
file_value = _read_local_file(local_path)
|
|
646
|
+
else:
|
|
647
|
+
if content is None:
|
|
648
|
+
raise ValueError("content or local_path is required")
|
|
649
|
+
file_value = _decode_content(content, content_base64)
|
|
650
|
+
adv = _resource_adv_from_dict(resource_advanced_properties)
|
|
651
|
+
result = _call_polygon(
|
|
652
|
+
polygon.problem_save_file,
|
|
653
|
+
problem_id,
|
|
654
|
+
file_type,
|
|
655
|
+
name,
|
|
656
|
+
file_value,
|
|
657
|
+
source_type=source_type,
|
|
658
|
+
resource_advanced_properties=adv,
|
|
659
|
+
)
|
|
660
|
+
return _to_jsonable(result)
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
@mcp.tool()
|
|
664
|
+
def problem_patch_file(
|
|
665
|
+
problem_id: int,
|
|
666
|
+
type: str,
|
|
667
|
+
name: str,
|
|
668
|
+
patch: str,
|
|
669
|
+
source_type: Optional[str] = None,
|
|
670
|
+
resource_advanced_properties: Optional[dict] = None,
|
|
671
|
+
) -> Any:
|
|
672
|
+
"""Apply a unified diff (single-file) patch to a text file and save it back.
|
|
673
|
+
|
|
674
|
+
The server reads the current file, applies the patch, and saves it back.
|
|
675
|
+
If the patch doesn't apply, it returns an error.
|
|
676
|
+
"""
|
|
677
|
+
polygon = _get_client()
|
|
678
|
+
file_type = _parse_file_type(type)
|
|
679
|
+
current = _call_polygon(polygon.problem_view_file, problem_id, file_type, name)
|
|
680
|
+
if not isinstance(current, str):
|
|
681
|
+
raise ValueError("File content is not text; patch edits are not supported")
|
|
682
|
+
if "\x00" in current:
|
|
683
|
+
raise ValueError("File appears to be binary; patch edits are not supported")
|
|
684
|
+
updated = _apply_unified_diff(current, patch, expected_name=name)
|
|
685
|
+
if updated == current:
|
|
686
|
+
raise ValueError("Patch did not change file content")
|
|
687
|
+
adv = _resource_adv_from_dict(resource_advanced_properties)
|
|
688
|
+
result = _call_polygon(
|
|
689
|
+
polygon.problem_save_file,
|
|
690
|
+
problem_id,
|
|
691
|
+
file_type,
|
|
692
|
+
name,
|
|
693
|
+
updated,
|
|
694
|
+
source_type=source_type,
|
|
695
|
+
resource_advanced_properties=adv,
|
|
696
|
+
)
|
|
697
|
+
return _to_jsonable(result)
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
@mcp.tool()
|
|
701
|
+
def problem_patch_statement(
|
|
702
|
+
problem_id: int,
|
|
703
|
+
lang: str,
|
|
704
|
+
section: str,
|
|
705
|
+
patch: str,
|
|
706
|
+
) -> Any:
|
|
707
|
+
"""Apply a unified diff patch to a statement section and save it back.
|
|
708
|
+
|
|
709
|
+
The server reads the current statement, applies the patch to the selected
|
|
710
|
+
section, and saves it back. If the patch doesn't apply, it returns an error.
|
|
711
|
+
Sections: legend, input, output, notes, tutorial, scoring, interaction.
|
|
712
|
+
"""
|
|
713
|
+
polygon = _get_client()
|
|
714
|
+
section_key = _normalize_statement_section(section)
|
|
715
|
+
try:
|
|
716
|
+
statements = _call_polygon(polygon.problem_statements, problem_id)
|
|
717
|
+
statement = statements.get(lang) if isinstance(statements, dict) else None
|
|
718
|
+
if statement is None:
|
|
719
|
+
raise ValueError(f"Statement not found for lang: {lang}")
|
|
720
|
+
current = getattr(statement, section_key, None)
|
|
721
|
+
current_text = current or ""
|
|
722
|
+
updated = _apply_unified_diff(current_text, patch, expected_name=section_key)
|
|
723
|
+
if updated == current_text:
|
|
724
|
+
raise ValueError("Patch did not change statement section")
|
|
725
|
+
statement_patch = Statement(**{section_key: updated})
|
|
726
|
+
result = _call_polygon(polygon.problem_save_statement, problem_id, lang, statement_patch)
|
|
727
|
+
except Exception as exc:
|
|
728
|
+
raise
|
|
729
|
+
return _to_jsonable(result)
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
@mcp.tool()
|
|
733
|
+
def problem_solutions(problem_id: int) -> Any:
|
|
734
|
+
polygon = _get_client()
|
|
735
|
+
result = _call_polygon(polygon.problem_solutions, problem_id)
|
|
736
|
+
return _to_jsonable(result)
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
@mcp.tool()
|
|
740
|
+
def problem_view_solution(
|
|
741
|
+
problem_id: int,
|
|
742
|
+
name: str,
|
|
743
|
+
start_line: Optional[int] = None,
|
|
744
|
+
line_count: Optional[int] = None,
|
|
745
|
+
) -> Any:
|
|
746
|
+
polygon = _get_client()
|
|
747
|
+
data = _call_polygon(polygon.problem_view_solution, problem_id, name)
|
|
748
|
+
data = _slice_lines(data, start_line, line_count)
|
|
749
|
+
return {"data": data, "encoding": "utf-8"}
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
@mcp.tool()
|
|
753
|
+
def problem_save_solution(
|
|
754
|
+
problem_id: int,
|
|
755
|
+
name: str,
|
|
756
|
+
source_type: str,
|
|
757
|
+
tag: str,
|
|
758
|
+
content: Optional[str] = None,
|
|
759
|
+
content_base64: bool = False,
|
|
760
|
+
local_path: Optional[str] = None,
|
|
761
|
+
check_existing: Optional[bool] = None,
|
|
762
|
+
) -> Any:
|
|
763
|
+
polygon = _get_client()
|
|
764
|
+
if local_path:
|
|
765
|
+
file_value = _read_local_file(local_path)
|
|
766
|
+
else:
|
|
767
|
+
if content is None:
|
|
768
|
+
raise ValueError("content or local_path is required")
|
|
769
|
+
file_value = _decode_content(content, content_base64)
|
|
770
|
+
result = _call_polygon(
|
|
771
|
+
polygon.problem_save_solution,
|
|
772
|
+
problem_id,
|
|
773
|
+
name,
|
|
774
|
+
file_value,
|
|
775
|
+
source_type,
|
|
776
|
+
tag,
|
|
777
|
+
check_existing=check_existing,
|
|
778
|
+
)
|
|
779
|
+
return _to_jsonable(result)
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
@mcp.tool()
|
|
783
|
+
def problem_tests(
|
|
784
|
+
problem_id: int,
|
|
785
|
+
testset: str,
|
|
786
|
+
no_inputs: Optional[bool] = None,
|
|
787
|
+
fields: Optional[list[str]] = None,
|
|
788
|
+
input_line_limit: Optional[int] = None,
|
|
789
|
+
examples_only: bool = False,
|
|
790
|
+
) -> Any:
|
|
791
|
+
"""List tests for a testset, optionally selecting fields.
|
|
792
|
+
|
|
793
|
+
fields can include: testset, index, group, points, description,
|
|
794
|
+
use_in_statements, input_for_statements, output_for_statements,
|
|
795
|
+
verify_input_output_for_statements, input (manual tests only), script_line (generated tests only).
|
|
796
|
+
For each test, only one of input or script_line is present (manual vs generated).
|
|
797
|
+
If input_line_limit is set, returned test inputs are truncated to the first N lines.
|
|
798
|
+
If examples_only is true, only tests with use_in_statements=true are returned.
|
|
799
|
+
"""
|
|
800
|
+
polygon = _get_client()
|
|
801
|
+
result = _call_polygon(polygon.problem_tests, problem_id, testset, no_inputs=no_inputs)
|
|
802
|
+
data = _to_jsonable(result)
|
|
803
|
+
if examples_only and isinstance(data, list):
|
|
804
|
+
data = [item for item in data if item.get("use_in_statements") is True]
|
|
805
|
+
if fields is None:
|
|
806
|
+
if input_line_limit is None or not isinstance(data, list):
|
|
807
|
+
return data
|
|
808
|
+
for item in data:
|
|
809
|
+
value = item.get("input")
|
|
810
|
+
if isinstance(value, str):
|
|
811
|
+
lines = value.splitlines(keepends=True)
|
|
812
|
+
item["input"] = "".join(lines[: max(0, input_line_limit)])
|
|
813
|
+
return data
|
|
814
|
+
field_set = set(fields)
|
|
815
|
+
if not isinstance(data, list):
|
|
816
|
+
return data
|
|
817
|
+
result = [{k: v for k, v in item.items() if k in field_set} for item in data]
|
|
818
|
+
if input_line_limit is not None and "input" in field_set:
|
|
819
|
+
for item in result:
|
|
820
|
+
value = item.get("input")
|
|
821
|
+
if isinstance(value, str):
|
|
822
|
+
lines = value.splitlines(keepends=True)
|
|
823
|
+
item["input"] = "".join(lines[: max(0, input_line_limit)])
|
|
824
|
+
return result
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
@mcp.tool()
|
|
828
|
+
def problem_test_answer(
|
|
829
|
+
problem_id: int,
|
|
830
|
+
testset: str,
|
|
831
|
+
test_index: int,
|
|
832
|
+
output_path: Optional[str] = None,
|
|
833
|
+
) -> Any:
|
|
834
|
+
"""Get generated test answer for a test.
|
|
835
|
+
|
|
836
|
+
If output_path is provided, the result is written to a local file.
|
|
837
|
+
"""
|
|
838
|
+
polygon = _get_client()
|
|
839
|
+
data = _call_polygon(polygon.problem_test_answer, problem_id, testset, test_index)
|
|
840
|
+
if output_path:
|
|
841
|
+
path = _resolve_output_path(output_path)
|
|
842
|
+
with open(path, "wb") as handle:
|
|
843
|
+
handle.write(data.encode("utf-8") if isinstance(data, str) else data)
|
|
844
|
+
return {"saved_to": path}
|
|
845
|
+
return {"data": data, "encoding": "utf-8"}
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
@mcp.tool()
|
|
849
|
+
def problem_test_input(
|
|
850
|
+
problem_id: int,
|
|
851
|
+
testset: str,
|
|
852
|
+
test_index: int,
|
|
853
|
+
output_path: Optional[str] = None,
|
|
854
|
+
) -> Any:
|
|
855
|
+
"""Get generated test input for a test.
|
|
856
|
+
|
|
857
|
+
If output_path is provided, the result is written to a local file.
|
|
858
|
+
"""
|
|
859
|
+
polygon = _get_client()
|
|
860
|
+
data = _call_polygon(polygon.problem_test_input, problem_id, testset, test_index)
|
|
861
|
+
if output_path:
|
|
862
|
+
path = _resolve_output_path(output_path)
|
|
863
|
+
with open(path, "wb") as handle:
|
|
864
|
+
handle.write(data.encode("utf-8") if isinstance(data, str) else data)
|
|
865
|
+
return {"saved_to": path}
|
|
866
|
+
return {"data": data, "encoding": "utf-8"}
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
@mcp.tool()
|
|
870
|
+
def problem_save_test(
|
|
871
|
+
problem_id: int,
|
|
872
|
+
testset: str,
|
|
873
|
+
test_index: int,
|
|
874
|
+
test_input: Optional[str] = None,
|
|
875
|
+
test_group: Optional[str] = None,
|
|
876
|
+
test_points: Optional[int] = None,
|
|
877
|
+
test_description: Optional[str] = None,
|
|
878
|
+
test_use_in_statements: Optional[bool] = None,
|
|
879
|
+
test_input_for_statements: Optional[str] = None,
|
|
880
|
+
test_output_for_statements: Optional[str] = None,
|
|
881
|
+
verify_input_output_for_statements: Optional[bool] = None,
|
|
882
|
+
check_existing: Optional[bool] = None,
|
|
883
|
+
test_input_base64: bool = False,
|
|
884
|
+
) -> Any:
|
|
885
|
+
"""Save or update a test.
|
|
886
|
+
|
|
887
|
+
test_input is optional when editing; omit it to keep the existing test input
|
|
888
|
+
and update only metadata (group/points/description/statement fields).
|
|
889
|
+
"""
|
|
890
|
+
polygon = _get_client()
|
|
891
|
+
input_value = None
|
|
892
|
+
if test_input is not None:
|
|
893
|
+
input_value = _decode_content(test_input, test_input_base64)
|
|
894
|
+
result = _call_polygon(
|
|
895
|
+
polygon.problem_save_test,
|
|
896
|
+
problem_id,
|
|
897
|
+
testset,
|
|
898
|
+
test_index,
|
|
899
|
+
input_value,
|
|
900
|
+
test_group=test_group,
|
|
901
|
+
test_points=test_points,
|
|
902
|
+
test_description=test_description,
|
|
903
|
+
test_use_in_statements=test_use_in_statements,
|
|
904
|
+
test_input_for_statements=test_input_for_statements,
|
|
905
|
+
test_output_for_statements=test_output_for_statements,
|
|
906
|
+
verify_input_output_for_statements=verify_input_output_for_statements,
|
|
907
|
+
check_existing=check_existing,
|
|
908
|
+
)
|
|
909
|
+
return _to_jsonable(result)
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
@mcp.tool()
|
|
913
|
+
def problem_enable_groups(problem_id: int, testset: str, enable: bool) -> Any:
|
|
914
|
+
polygon = _get_client()
|
|
915
|
+
result = _call_polygon(polygon.problem_enable_groups, problem_id, testset, enable)
|
|
916
|
+
return _to_jsonable(result)
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
@mcp.tool()
|
|
920
|
+
def problem_enable_points(problem_id: int, enable: bool) -> Any:
|
|
921
|
+
polygon = _get_client()
|
|
922
|
+
result = _call_polygon(polygon.problem_enable_points, problem_id, enable)
|
|
923
|
+
return _to_jsonable(result)
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
@mcp.tool()
|
|
927
|
+
def problem_view_test_group(testset: str, group: str) -> Any:
|
|
928
|
+
polygon = _get_client()
|
|
929
|
+
result = _call_polygon(polygon.problem_view_test_group, testset, group)
|
|
930
|
+
return _to_jsonable(result)
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
@mcp.tool()
|
|
934
|
+
def problem_save_test_group(
|
|
935
|
+
problem_id: int,
|
|
936
|
+
testset: str,
|
|
937
|
+
group: str,
|
|
938
|
+
points_policy: Optional[str] = None,
|
|
939
|
+
feedback_policy: Optional[str] = None,
|
|
940
|
+
dependencies: Optional[list] = None,
|
|
941
|
+
) -> Any:
|
|
942
|
+
"""Save or update a test group.
|
|
943
|
+
|
|
944
|
+
points_policy: COMPLETE_GROUP or EACH_TEST
|
|
945
|
+
feedback_policy: NONE, POINTS, ICPC, COMPLETE
|
|
946
|
+
"""
|
|
947
|
+
polygon = _get_client()
|
|
948
|
+
points_policy_enum = _parse_enum(PointsPolicy, points_policy) if points_policy is not None else None
|
|
949
|
+
feedback_policy_enum = _parse_enum(FeedbackPolicy, feedback_policy) if feedback_policy is not None else None
|
|
950
|
+
result = _call_polygon(
|
|
951
|
+
polygon.problem_save_test_group,
|
|
952
|
+
problem_id,
|
|
953
|
+
testset,
|
|
954
|
+
group,
|
|
955
|
+
points_policy=points_policy_enum,
|
|
956
|
+
feedback_policy=feedback_policy_enum,
|
|
957
|
+
dependencies=dependencies,
|
|
958
|
+
)
|
|
959
|
+
return _to_jsonable(result)
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
@mcp.tool()
|
|
963
|
+
def problem_view_tags(problem_id: int) -> Any:
|
|
964
|
+
polygon = _get_client()
|
|
965
|
+
result = _call_polygon(polygon.problem_view_tags, problem_id)
|
|
966
|
+
return _to_jsonable(result)
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
@mcp.tool()
|
|
970
|
+
def problem_save_tags(problem_id: int, tags: list[str]) -> Any:
|
|
971
|
+
polygon = _get_client()
|
|
972
|
+
result = _call_polygon(polygon.problem_save_tags, problem_id, tags)
|
|
973
|
+
return _to_jsonable(result)
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
@mcp.tool()
|
|
977
|
+
def problem_view_general_description(problem_id: int) -> Any:
|
|
978
|
+
polygon = _get_client()
|
|
979
|
+
result = _call_polygon(polygon.problem_view_general_description, problem_id)
|
|
980
|
+
return _to_jsonable(result)
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
@mcp.tool()
|
|
984
|
+
def problem_save_general_description(problem_id: int, description: str) -> Any:
|
|
985
|
+
polygon = _get_client()
|
|
986
|
+
result = _call_polygon(polygon.problem_save_general_description, problem_id, description)
|
|
987
|
+
return _to_jsonable(result)
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
@mcp.tool()
|
|
991
|
+
def problem_view_general_tutorial(problem_id: int) -> Any:
|
|
992
|
+
polygon = _get_client()
|
|
993
|
+
result = _call_polygon(polygon.problem_view_general_tutorial, problem_id)
|
|
994
|
+
return _to_jsonable(result)
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
@mcp.tool()
|
|
998
|
+
def problem_save_general_tutorial(problem_id: int, tutorial: str) -> Any:
|
|
999
|
+
polygon = _get_client()
|
|
1000
|
+
result = _call_polygon(polygon.problem_save_general_tutorial, problem_id, tutorial)
|
|
1001
|
+
return _to_jsonable(result)
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
@mcp.tool()
|
|
1005
|
+
def contest_problems(contest_id: int) -> Any:
|
|
1006
|
+
polygon = _get_client()
|
|
1007
|
+
result = _call_polygon(polygon.contest_problems, contest_id)
|
|
1008
|
+
return _to_jsonable(result)
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
@mcp.tool()
|
|
1012
|
+
def problem_packages(problem_id: int) -> Any:
|
|
1013
|
+
"""List packages available for the problem."""
|
|
1014
|
+
polygon = _get_client()
|
|
1015
|
+
result = _call_polygon(polygon.problem_packages, problem_id)
|
|
1016
|
+
return _to_jsonable(result)
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
@mcp.tool()
|
|
1020
|
+
def problem_build_package(problem_id: int, full: bool, verify: bool) -> Any:
|
|
1021
|
+
"""Start building a new package."""
|
|
1022
|
+
if full:
|
|
1023
|
+
raise ValueError("full packages are disabled; set full=false")
|
|
1024
|
+
polygon = _get_client()
|
|
1025
|
+
result = _call_polygon(polygon.problem_build_package, problem_id, full, verify)
|
|
1026
|
+
return _to_jsonable(result)
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
if __name__ == "__main__":
|
|
1030
|
+
mcp.run()
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: polygon-mcp-server
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for Codeforces Polygon API
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: polygon_api==1.1.0a1
|
|
8
|
+
Requires-Dist: fastmcp<3
|
|
9
|
+
Requires-Dist: patch-ng
|
|
10
|
+
|
|
11
|
+
# Polygon MCP Server
|
|
12
|
+
|
|
13
|
+
MCP server for Polygon API (Codeforces Polygon).
|
|
14
|
+
|
|
15
|
+
## Requirements
|
|
16
|
+
|
|
17
|
+
- Python 3.10+
|
|
18
|
+
- `polygon_api==1.1.0a1`
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
### From PyPI repository
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install polygon-mcp-server
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
With uv:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
uv pip install polygon-mcp-server
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### From the sources
|
|
35
|
+
|
|
36
|
+
Install the package (adds the `polygon-mcp` CLI):
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install .
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
With uv:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
uv pip install .
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Or install the CLI tool into uv's tool environment:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
uv tool install .
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Run
|
|
55
|
+
|
|
56
|
+
Set credentials:
|
|
57
|
+
|
|
58
|
+
- `POLYGON_API_KEY`
|
|
59
|
+
- `POLYGON_API_SECRET`
|
|
60
|
+
- Optional: `POLYGON_API_URL`
|
|
61
|
+
- Optional: `POLYGON_MCP_CONFIG` to load stored credentials
|
|
62
|
+
|
|
63
|
+
Then start:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
polygon-mcp
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Logging
|
|
70
|
+
|
|
71
|
+
Logs are written to `~/.local/state/polygon-mcp/polygon-mcp.log` (or
|
|
72
|
+
`$XDG_STATE_HOME/polygon-mcp/polygon-mcp.log` if set). Override with
|
|
73
|
+
`POLYGON_MCP_LOG_FILE`.
|
|
74
|
+
|
|
75
|
+
## File output safety
|
|
76
|
+
|
|
77
|
+
Tools that write to disk only allow paths within:
|
|
78
|
+
|
|
79
|
+
- the current project directory
|
|
80
|
+
- `/tmp`
|
|
81
|
+
- any extra roots in `POLYGON_MCP_OUTPUT_ROOTS` (colon-separated)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
polygon_mcp/__init__.py,sha256=gXvDHD0ZhWT76B3yvx1UcO1LirlOksdxnyROmAY2tJs,70
|
|
2
|
+
polygon_mcp/__main__.py,sha256=9HlMHQ2cj2K9BUr1DoPL4Sxp3p_ekxPHxbTlUOGLa6c,100
|
|
3
|
+
polygon_mcp/server.py,sha256=go5SmqJfcNJ3jraMVTPPpGO0mQNcDUWI6oZ2WCaqwjo,32977
|
|
4
|
+
polygon_mcp_server-0.1.0.dist-info/METADATA,sha256=xoc3Gwpu0EXrXLriUA0RajsZuCkkqkmCNhOzzkM8L3Y,1306
|
|
5
|
+
polygon_mcp_server-0.1.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
6
|
+
polygon_mcp_server-0.1.0.dist-info/entry_points.txt,sha256=5PXQVKSKsYtmZKJXghFlXMHzNrys1hdW-lcjkQmKIW4,58
|
|
7
|
+
polygon_mcp_server-0.1.0.dist-info/top_level.txt,sha256=rh_UcWwNLGw86a3QHkPRLlPyDCeD6-6jHKRoqEdZhTo,12
|
|
8
|
+
polygon_mcp_server-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
polygon_mcp
|