polygon-mcp-server 0.1.0__tar.gz

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,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,71 @@
1
+ # Polygon MCP Server
2
+
3
+ MCP server for Polygon API (Codeforces Polygon).
4
+
5
+ ## Requirements
6
+
7
+ - Python 3.10+
8
+ - `polygon_api==1.1.0a1`
9
+
10
+ ## Install
11
+
12
+ ### From PyPI repository
13
+
14
+ ```bash
15
+ pip install polygon-mcp-server
16
+ ```
17
+
18
+ With uv:
19
+
20
+ ```bash
21
+ uv pip install polygon-mcp-server
22
+ ```
23
+
24
+ ### From the sources
25
+
26
+ Install the package (adds the `polygon-mcp` CLI):
27
+
28
+ ```bash
29
+ pip install .
30
+ ```
31
+
32
+ With uv:
33
+
34
+ ```bash
35
+ uv pip install .
36
+ ```
37
+
38
+ Or install the CLI tool into uv's tool environment:
39
+
40
+ ```bash
41
+ uv tool install .
42
+ ```
43
+
44
+ ## Run
45
+
46
+ Set credentials:
47
+
48
+ - `POLYGON_API_KEY`
49
+ - `POLYGON_API_SECRET`
50
+ - Optional: `POLYGON_API_URL`
51
+ - Optional: `POLYGON_MCP_CONFIG` to load stored credentials
52
+
53
+ Then start:
54
+
55
+ ```bash
56
+ polygon-mcp
57
+ ```
58
+
59
+ ## Logging
60
+
61
+ Logs are written to `~/.local/state/polygon-mcp/polygon-mcp.log` (or
62
+ `$XDG_STATE_HOME/polygon-mcp/polygon-mcp.log` if set). Override with
63
+ `POLYGON_MCP_LOG_FILE`.
64
+
65
+ ## File output safety
66
+
67
+ Tools that write to disk only allow paths within:
68
+
69
+ - the current project directory
70
+ - `/tmp`
71
+ - any extra roots in `POLYGON_MCP_OUTPUT_ROOTS` (colon-separated)
@@ -0,0 +1,5 @@
1
+ """Polygon MCP server."""
2
+
3
+ from .server import mcp
4
+
5
+ __all__ = ["mcp"]
@@ -0,0 +1,9 @@
1
+ from .server import mcp
2
+
3
+
4
+ def main() -> None:
5
+ mcp.run()
6
+
7
+
8
+ if __name__ == "__main__":
9
+ main()
@@ -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,11 @@
1
+ README.md
2
+ pyproject.toml
3
+ polygon_mcp/__init__.py
4
+ polygon_mcp/__main__.py
5
+ polygon_mcp/server.py
6
+ polygon_mcp_server.egg-info/PKG-INFO
7
+ polygon_mcp_server.egg-info/SOURCES.txt
8
+ polygon_mcp_server.egg-info/dependency_links.txt
9
+ polygon_mcp_server.egg-info/entry_points.txt
10
+ polygon_mcp_server.egg-info/requires.txt
11
+ polygon_mcp_server.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ polygon-mcp = polygon_mcp.__main__:main
@@ -0,0 +1,3 @@
1
+ polygon_api==1.1.0a1
2
+ fastmcp<3
3
+ patch-ng
@@ -0,0 +1,21 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "polygon-mcp-server"
7
+ version = "0.1.0"
8
+ description = "MCP server for Codeforces Polygon API"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ dependencies = [
12
+ "polygon_api==1.1.0a1",
13
+ "fastmcp<3",
14
+ "patch-ng",
15
+ ]
16
+
17
+ [project.scripts]
18
+ polygon-mcp = "polygon_mcp.__main__:main"
19
+
20
+ [tool.setuptools]
21
+ packages = ["polygon_mcp"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+