agentic-mcp-solver 4.2.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,508 @@
1
+ """mcp-solver-serve: the v4 MCP server — a solver toolkit for host LLMs.
2
+
3
+ The HOST does the solving. Claude Desktop, Claude Code, or mcp-minion (the
4
+ batchable open-source host substitute) connects here and writes, runs, and
5
+ verifies solver programs itself against a persistent IPython kernel. The
6
+ server contributes the solver value: backend selection, solver packages and
7
+ helpers injected into the kernel, the modeling instructions (templates), and
8
+ the compile-gated ``submit_code`` finish line. It calls no LLM and needs no
9
+ API key.
10
+
11
+ Kernel model: ``select_backend`` sets up ONE solving kernel and recycles it
12
+ (state cleared, packages swapped) on every later call, so solving problem
13
+ after problem never accumulates kernel processes. Bare ``python_exec``/
14
+ ``python_interrupt`` calls are routed to that kernel automatically; an
15
+ explicit ``kernel_id`` overrides the routing. A manual ``python_reset``
16
+ without ``kernel_id`` creates an additional, independent kernel (which then
17
+ becomes the routing target) — those live until the server exits.
18
+
19
+ Successive revelation to the host:
20
+ - Tier 0: tool descriptions — enough to start (call ``select_backend`` first).
21
+ - Tier 1: ``mcp-solver://guide`` and, on backend selection, the full modeling
22
+ instructions for that backend (also browsable as
23
+ ``mcp-solver://template/{solver}``).
24
+ - Tier 2: successful submissions become resources
25
+ (``mcp-solver://submissions/{id}``), linked from the submit_code result.
26
+
27
+ The kernel tools are proxied over stdio to the engine's ``ipython_mcp``
28
+ server (agentic-python-coder), using only its public MCP contract; one
29
+ long-lived engine connection per server process, so kernel state persists
30
+ across the host's tool calls.
31
+ """
32
+
33
+ import asyncio
34
+ import contextlib
35
+ import json
36
+ import logging
37
+ import math
38
+ import os
39
+ import sys
40
+ import time
41
+ import uuid
42
+ from collections.abc import AsyncIterator
43
+ from contextlib import asynccontextmanager
44
+ from datetime import UTC, datetime
45
+
46
+ try: # mcp 2.x
47
+ from mcp.server.mcpserver import MCPServer as FastMCP
48
+ from mcp.server.mcpserver.exceptions import ResourceError, ToolError
49
+ except ImportError: # mcp 1.x
50
+ from mcp.server.fastmcp import FastMCP
51
+ from mcp.server.fastmcp.exceptions import ResourceError, ToolError
52
+ from mcp.types import CallToolResult, ResourceLink, TextContent
53
+
54
+ import mcp_solver
55
+ from mcp_solver.agent.cli import (
56
+ _is_unpublishable,
57
+ _local_checkout,
58
+ build_with_packages,
59
+ )
60
+ from mcp_solver.templates import SOLVERS, get_template
61
+
62
+ SELECT_BACKEND_DESCRIPTION = """\
63
+ Start solving a constraint problem: call this FIRST, once per problem.
64
+
65
+ Sets up a persistent IPython kernel preloaded with the backend's solver
66
+ library and helper functions, and returns the modeling instructions for that
67
+ backend. Calling it again recycles the kernel for a new problem (previous
68
+ session state is cleared).
69
+
70
+ Backends:
71
+ - pysat: Boolean satisfiability — feasibility, combinatorial search
72
+ - maxsat: weighted optimization over Boolean constraints
73
+ - z3: SMT — integers/reals/bitvectors, proofs, program verification
74
+ - cpmpy: finite-domain constraint programming — scheduling, assignment, puzzles
75
+ - clingo: Answer Set Programming — logic rules, defaults, reachability
76
+ - didp: dynamic programming (state-space search) — routing, sequencing, packing
77
+
78
+ Unsure which backend fits? Read the resource mcp-solver://guide first.
79
+
80
+ Then iterate with python_exec (write, run, verify against the problem
81
+ statement), and finish by calling submit_code with the final, verified,
82
+ self-contained program. UNSAT / "no solution exists" is a valid outcome.
83
+ """
84
+
85
+ GUIDE = """\
86
+ # mcp-solver: how to solve a problem
87
+
88
+ Workflow: (1) `select_backend(solver)` — sets up the solving kernel with the
89
+ right solver library and returns modeling instructions; (2) iterate
90
+ `python_exec` (it runs in that kernel automatically): encode, run the real
91
+ solver, verify the result independently against the problem statement;
92
+ (3) finish with `submit_code` (the final, verified, self-contained program).
93
+
94
+ Choosing a backend:
95
+ - **pysat** — pure Boolean structure: placements, colorings, covers,
96
+ pigeonhole-style feasibility. Fastest when everything is yes/no variables.
97
+ - **maxsat** — like pysat but with costs or preferences: maximize satisfied
98
+ soft constraints under hard ones. Finds the true optimum, not a heuristic.
99
+ - **z3** — arithmetic and verification: integer/real/bitvector constraints,
100
+ cryptarithmetic, proving a property holds (UNSAT of the negation),
101
+ finding counterexamples, induction obligations.
102
+ - **cpmpy** — finite-domain CP: rosters, timetables, routing-style puzzles,
103
+ global constraints (all-different, cumulative), optimization over integers.
104
+ - **clingo** — rule-based knowledge: transitive closure/reachability,
105
+ defaults with exceptions, choice under logical rules, #minimize/#maximize.
106
+ - **didp** — dynamic programming as state-space search (didppy): sequential
107
+ decisions with a natural state — routing with time windows, sequencing,
108
+ scheduling, knapsack/packing. Often beats CP on these; optimality proofs
109
+ via anytime beam search.
110
+
111
+ Tips: encode exactly the stated problem (every number matters); UNSAT is a
112
+ legitimate answer, re-check the encoding before reporting it; keep the final
113
+ program self-contained (all imports, no session state) and print only the
114
+ solution JSON. Long solver runs: python_exec defaults to a 30 s limit —
115
+ raise its `timeout` (up to 300) and give the solver an explicit time limit
116
+ rather than concluding the model is wrong.
117
+ """
118
+
119
+ # Engine connection (one per server process; kernels persist across calls).
120
+ _engine = None
121
+
122
+ # The solving kernel: created by the first select_backend, recycled by later
123
+ # ones. Bare python_exec/python_interrupt calls are routed here; a manual
124
+ # python_reset that creates or resets a kernel retargets the routing.
125
+ # FastMCP dispatches tool calls concurrently, so every read-await-write of
126
+ # _kernel_id happens under this lock.
127
+ _kernel_id: str | None = None
128
+ _kernel_lock = asyncio.Lock()
129
+
130
+ # Server-side execution cap of the engine's python_exec (seconds); clamp
131
+ # here too so an oversized host value cannot stretch the client-side wait.
132
+ _MAX_EXEC_TIMEOUT = 300
133
+
134
+ # Bounded store of successful submissions (Tier 2 resources).
135
+ _submissions: dict[str, str] = {}
136
+ _MAX_SUBMISSIONS = 50
137
+
138
+ # Solve statistics per episode (episode = one select_backend up to the next
139
+ # one, or server shutdown). The host's tokens are invisible to the server,
140
+ # but its tool usage is not: counts, exec failures, submissions, wall time.
141
+ # One JSONL line per episode goes to $MCP_SOLVER_STATS when set; a compact
142
+ # snapshot rides along in submit_code's structuredContent.
143
+ _episode: dict | None = None
144
+
145
+
146
+ def _episode_open(solver: str) -> None:
147
+ global _episode
148
+ _episode_close("superseded")
149
+ _episode = {
150
+ "solver": solver,
151
+ "started": datetime.now(UTC).isoformat(timespec="seconds"),
152
+ "t0": time.monotonic(),
153
+ "tool_calls": {"select_backend": 1},
154
+ "exec_failures": 0,
155
+ "submit_attempts": 0,
156
+ "submit_ok": 0,
157
+ "code_bytes": None,
158
+ }
159
+
160
+
161
+ def _episode_record(end: str) -> dict:
162
+ record = {k: v for k, v in _episode.items() if k != "t0"}
163
+ record["wall_seconds"] = round(time.monotonic() - _episode["t0"], 1)
164
+ record["end"] = end
165
+ return record
166
+
167
+
168
+ def _episode_sync() -> None:
169
+ """Snapshot the open episode to <stats>.open after every counted call.
170
+
171
+ Hosts often kill the server without a clean shutdown (claude -p does),
172
+ so waiting for episode close would lose single-episode sessions
173
+ entirely. The snapshot is overwritten in place and removed on close.
174
+ """
175
+ path = os.environ.get("MCP_SOLVER_STATS")
176
+ if not path or _episode is None:
177
+ return
178
+ try:
179
+ with open(path + ".open", "w", encoding="utf-8") as fh:
180
+ fh.write(json.dumps(_episode_record("open")) + "\n")
181
+ except OSError as e:
182
+ print(f"mcp-solver-serve: cannot write stats to {path}: {e}", file=sys.stderr)
183
+
184
+
185
+ def _episode_close(end: str) -> None:
186
+ global _episode
187
+ if _episode is None:
188
+ return
189
+ record = _episode_record(end)
190
+ _episode = None
191
+ path = os.environ.get("MCP_SOLVER_STATS")
192
+ if not path:
193
+ return
194
+ try:
195
+ with open(path, "a", encoding="utf-8") as fh:
196
+ fh.write(json.dumps(record) + "\n")
197
+ with contextlib.suppress(FileNotFoundError):
198
+ os.remove(path + ".open")
199
+ except OSError as e:
200
+ print(f"mcp-solver-serve: cannot write stats to {path}: {e}", file=sys.stderr)
201
+
202
+
203
+ def _episode_count(tool: str) -> None:
204
+ if _episode is not None:
205
+ calls = _episode["tool_calls"]
206
+ calls[tool] = calls.get(tool, 0) + 1
207
+ _episode_sync()
208
+
209
+
210
+ def _episode_snapshot() -> dict | None:
211
+ if _episode is None:
212
+ return None
213
+ return {
214
+ "exec_calls": _episode["tool_calls"].get("python_exec", 0),
215
+ "exec_failures": _episode["exec_failures"],
216
+ "submit_attempts": _episode["submit_attempts"],
217
+ "wall_seconds": round(time.monotonic() - _episode["t0"], 1),
218
+ }
219
+
220
+
221
+ def _dev_path() -> str | None:
222
+ """Dev-mode source checkout for helpers/templates (env or auto-detect)."""
223
+ env = os.environ.get("MCP_SOLVER_DEV")
224
+ if env:
225
+ if env.lower() in ("1", "true", "auto"):
226
+ return _local_checkout()
227
+ return env
228
+ return _local_checkout()
229
+
230
+
231
+ @asynccontextmanager
232
+ async def _lifespan(server: FastMCP) -> AsyncIterator[None]:
233
+ """Hold one engine (ipython_mcp) connection for the server's lifetime."""
234
+ global _engine, _kernel_id
235
+ from mcp_minion import MCPManager
236
+
237
+ manager = MCPManager(
238
+ {
239
+ "ipython": {
240
+ "command": sys.executable,
241
+ "args": ["-m", "agentic_python_coder.mcp_server"],
242
+ }
243
+ }
244
+ )
245
+ await manager.__aenter__()
246
+ if not manager.has_tool("submit_code"):
247
+ await manager.__aexit__(None, None, None)
248
+ raise RuntimeError(
249
+ "the ipython_mcp engine is unavailable or lacks submit_code;"
250
+ " install agentic-python-coder >=3.4.3 in this environment"
251
+ )
252
+ _engine = manager
253
+ try:
254
+ yield
255
+ finally:
256
+ _episode_close("shutdown")
257
+ _engine = None
258
+ _kernel_id = None
259
+ await manager.__aexit__(None, None, None)
260
+
261
+
262
+ mcp = FastMCP(
263
+ "mcp-solver",
264
+ instructions=(
265
+ "Constraint-solving toolkit: call select_backend(solver) first, then"
266
+ " write and verify a solver program via python_exec, and finish with"
267
+ " submit_code. Backend choice: see resource mcp-solver://guide."
268
+ ),
269
+ lifespan=_lifespan,
270
+ )
271
+
272
+
273
+ async def _engine_call(name: str, arguments: dict) -> str:
274
+ """Proxy one tool call to the engine; unwrap the client's JSON envelope.
275
+
276
+ The envelope's ``error`` key covers client-level failures (engine down,
277
+ timeout). Engine-level outcomes — including failed executions — arrive
278
+ inside ``result`` as the engine tool's own JSON text and pass through to
279
+ the host untouched.
280
+ """
281
+ if _engine is None:
282
+ raise ToolError("engine not connected (server starting or shut down)")
283
+ raw = await _engine.call_tool(name, arguments)
284
+ try:
285
+ payload = json.loads(raw)
286
+ except json.JSONDecodeError as e:
287
+ raise ToolError(f"engine returned a malformed envelope: {e}") from None
288
+ if not isinstance(payload, dict):
289
+ raise ToolError("engine returned a malformed envelope (not a JSON object)")
290
+ if "error" in payload:
291
+ raise ToolError(str(payload["error"]))
292
+ return str(payload.get("result", ""))
293
+
294
+
295
+ def _parse_engine_json(text: str) -> dict:
296
+ """Parse an engine tool's JSON text; {} when it isn't a JSON object."""
297
+ try:
298
+ parsed = json.loads(text)
299
+ except json.JSONDecodeError:
300
+ return {}
301
+ return parsed if isinstance(parsed, dict) else {}
302
+
303
+
304
+ @mcp.resource("mcp-solver://guide", mime_type="text/markdown")
305
+ def guide() -> str:
306
+ """Backend selection and solving workflow guide."""
307
+ return GUIDE
308
+
309
+
310
+ @mcp.resource("mcp-solver://template/{solver}", mime_type="text/markdown")
311
+ def template_resource(solver: str) -> str:
312
+ """The full modeling instructions for a backend."""
313
+ if solver not in SOLVERS:
314
+ raise ResourceError(f"unknown solver {solver!r}; expected one of {SOLVERS}")
315
+ return get_template(solver, root=_dev_path())
316
+
317
+
318
+ @mcp.resource("mcp-solver://submissions/{submission_id}", mime_type="text/x-python")
319
+ def submission_resource(submission_id: str) -> str:
320
+ """A previously submitted (compile-checked) solver program."""
321
+ code = _submissions.get(submission_id)
322
+ if code is None:
323
+ raise ResourceError(f"unknown submission {submission_id!r}")
324
+ return code
325
+
326
+
327
+ @mcp.tool(description=SELECT_BACKEND_DESCRIPTION, structured_output=False)
328
+ async def select_backend(solver: str) -> str:
329
+ global _kernel_id
330
+ if solver not in SOLVERS:
331
+ raise ToolError(f"unknown solver {solver!r}; expected one of {SOLVERS}")
332
+
333
+ dev = _dev_path()
334
+ if dev is None and _is_unpublishable(mcp_solver.__version__):
335
+ raise ToolError(
336
+ f"mcp-solver {mcp_solver.__version__} is not a published release"
337
+ " and no source checkout was found; set MCP_SOLVER_DEV to a"
338
+ " checkout path"
339
+ )
340
+ packages = build_with_packages(solver, dev)
341
+
342
+ # Recycle the existing solving kernel; fresh kernel on first call or
343
+ # when the stored one is gone (engine reports success=false for it).
344
+ recycled = False
345
+ parsed: dict = {}
346
+ async with _kernel_lock:
347
+ if _kernel_id is not None:
348
+ parsed = _parse_engine_json(
349
+ await _engine_call(
350
+ "python_reset", {"packages": packages, "kernel_id": _kernel_id}
351
+ )
352
+ )
353
+ recycled = bool(parsed.get("success"))
354
+ if not recycled:
355
+ parsed = _parse_engine_json(
356
+ await _engine_call("python_reset", {"packages": packages})
357
+ )
358
+ if not parsed.get("success"):
359
+ raise ToolError(
360
+ f"kernel setup for {solver!r} failed:"
361
+ f" {parsed.get('error') or 'unknown engine error'}"
362
+ )
363
+ _kernel_id = parsed.get("kernel_id") or _kernel_id
364
+ _episode_open(solver)
365
+ _episode_sync()
366
+
367
+ state_note = (
368
+ "The solving kernel was recycled: previous session state is cleared."
369
+ if recycled
370
+ else "A fresh solving kernel is ready."
371
+ )
372
+ kernel_note = (
373
+ f"{state_note} Packages: {', '.join(packages)}. python_exec runs in"
374
+ " this kernel automatically."
375
+ " Finish with submit_code(final verified self-contained program)."
376
+ )
377
+ template = get_template(solver, root=dev)
378
+ # Plain text on purpose: the template IS the payload, and clients that
379
+ # prefer structuredContent over text (Claude Code does) would otherwise
380
+ # hide it from the model. Field-tested 2026-07-12.
381
+ return f"{template}\n\n---\n\n{kernel_note}"
382
+
383
+
384
+ @mcp.tool(structured_output=False)
385
+ async def python_exec(
386
+ code: str, timeout: float = 30, kernel_id: str | None = None
387
+ ) -> str:
388
+ """Execute Python code in the persistent solving kernel.
389
+
390
+ Runs in the kernel set up by select_backend unless kernel_id overrides
391
+ it. State persists across calls (variables, imports). Raise timeout
392
+ (seconds, max 300) for long solver runs.
393
+ """
394
+ _episode_count("python_exec")
395
+ if not math.isfinite(timeout):
396
+ timeout = 30.0
397
+ args: dict = {"code": code, "timeout": max(1.0, min(timeout, _MAX_EXEC_TIMEOUT))}
398
+ target = kernel_id or _kernel_id
399
+ if target:
400
+ args["kernel_id"] = target
401
+ text = await _engine_call("python_exec", args)
402
+ if _episode is not None and _parse_engine_json(text).get("success") is False:
403
+ _episode["exec_failures"] += 1
404
+ _episode_sync()
405
+ return text
406
+
407
+
408
+ @mcp.tool(structured_output=False)
409
+ async def python_reset(
410
+ packages: list[str] | None = None, kernel_id: str | None = None
411
+ ) -> str:
412
+ """Create a new kernel (no kernel_id) or reset one (with kernel_id).
413
+
414
+ Prefer select_backend, which installs the right solver packages for you.
415
+ The kernel created or reset here becomes the target of bare python_exec
416
+ calls.
417
+ """
418
+ global _kernel_id
419
+ _episode_count("python_reset")
420
+ args = {"packages": packages or []}
421
+ if kernel_id:
422
+ args["kernel_id"] = kernel_id
423
+ async with _kernel_lock:
424
+ text = await _engine_call("python_reset", args)
425
+ parsed = _parse_engine_json(text)
426
+ if parsed.get("success") and parsed.get("kernel_id"):
427
+ _kernel_id = str(parsed["kernel_id"])
428
+ return text
429
+
430
+
431
+ @mcp.tool(structured_output=False)
432
+ async def python_status(kernel_id: str | None = None) -> str:
433
+ """Check kernel state: active kernels, defined variables, packages."""
434
+ _episode_count("python_status")
435
+ args: dict = {}
436
+ if kernel_id:
437
+ args["kernel_id"] = kernel_id
438
+ return await _engine_call("python_status", args)
439
+
440
+
441
+ @mcp.tool(structured_output=False)
442
+ async def python_interrupt(kernel_id: str | None = None) -> str:
443
+ """Interrupt running code in the solving kernel (state is preserved)."""
444
+ _episode_count("python_interrupt")
445
+ args: dict = {}
446
+ target = kernel_id or _kernel_id
447
+ if target:
448
+ args["kernel_id"] = target
449
+ return await _engine_call("python_interrupt", args)
450
+
451
+
452
+ @mcp.tool()
453
+ async def submit_code(code: str) -> CallToolResult:
454
+ """Submit the final, verified, self-contained solver program.
455
+
456
+ Call ONCE at the end, only after executing the program via python_exec
457
+ and passing your verification. The code is syntax-checked; on success it
458
+ is stored and linked as a resource (the server keeps the last 50).
459
+ """
460
+ # Count the attempt BEFORE _episode_count syncs the .open snapshot, so a
461
+ # failed submission (which never syncs again) is not lost on a host kill.
462
+ if _episode is not None:
463
+ _episode["submit_attempts"] += 1
464
+ _episode_count("submit_code")
465
+ result_text = await _engine_call("submit_code", {"code": code})
466
+ verdict = _parse_engine_json(result_text)
467
+
468
+ content: list = [TextContent(type="text", text=result_text)]
469
+ if verdict.get("ok"):
470
+ if _episode is not None:
471
+ _episode["submit_ok"] += 1
472
+ _episode["code_bytes"] = len(code.encode())
473
+ _episode_sync()
474
+ submission_id = uuid.uuid4().hex
475
+ _submissions[submission_id] = code
476
+ while len(_submissions) > _MAX_SUBMISSIONS:
477
+ _submissions.pop(next(iter(_submissions)))
478
+ content.append(
479
+ ResourceLink(
480
+ type="resource_link",
481
+ uri=f"mcp-solver://submissions/{submission_id}",
482
+ name=f"submission_{submission_id[:8]}.py",
483
+ description="The accepted solver program (compile-checked).",
484
+ mimeType="text/x-python",
485
+ size=len(code.encode()),
486
+ )
487
+ )
488
+ structured = dict(verdict) if verdict else {}
489
+ stats = _episode_snapshot()
490
+ if stats:
491
+ structured["stats"] = stats
492
+ return CallToolResult(
493
+ content=content,
494
+ structuredContent=structured or None,
495
+ )
496
+
497
+
498
+ def main() -> None:
499
+ """Entry point for mcp-solver-serve (stdio transport)."""
500
+ # The mcp library logs every request at INFO ("Processing request of
501
+ # type CallToolRequest"), which is pure noise on the host's console
502
+ # or log; keep warnings and errors.
503
+ logging.getLogger("mcp").setLevel(logging.WARNING)
504
+ mcp.run()
505
+
506
+
507
+ if __name__ == "__main__":
508
+ main()
@@ -0,0 +1,159 @@
1
+ """Materialize mcp-minion run folders for benchmark problems.
2
+
3
+ A run folder (config.json + task.md) points mcp-minion at mcp-solver-serve
4
+ with instructions to solve one benchmark problem using one specific backend:
5
+
6
+ mcp-solver-runfolder didp tsptw ~/demos/tsptw
7
+ uv run --project <checkout> --extra agent mcp-minion -v ~/demos/tsptw
8
+
9
+ The problem text comes from ``tests/problems/<solver>/<problem>.md`` in the
10
+ source checkout (problems are not shipped as package data, so this tool
11
+ requires running from a checkout). The same helpers back the live e2e tests
12
+ in ``minion/e2e_tests/test_problems_e2e.py``, so a generated folder and an
13
+ e2e test run are the same episode.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import json
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ from mcp_solver.agent.cli import _local_checkout
24
+ from mcp_solver.templates import SOLVERS
25
+
26
+ DEFAULT_MODEL = "openai/gpt-5.6-terra"
27
+
28
+ # Prepended to every problem statement: the agent must use the requested
29
+ # backend and finish with a submission. The backend-substitution clause
30
+ # exists because agents otherwise silently fall back to another backend
31
+ # when a select_backend call fails.
32
+ PREAMBLE = """\
33
+ Solve this problem using the mcp-solver tools: call select_backend with the
34
+ {solver} backend and follow its modeling instructions. Use the
35
+ {solver} backend ONLY - do not substitute another backend. Finish with
36
+ submit_code, then give the solution JSON as your final answer.
37
+
38
+ ---
39
+
40
+ """
41
+
42
+ # Substrings (case-insensitive, any match) whose presence in a submitted
43
+ # program indicates the backend's solver library was genuinely used. The
44
+ # e2e tests use this to detect silent backend substitution.
45
+ SOLVER_MARKERS: dict[str, list[str]] = {
46
+ "pysat": ["pysat"],
47
+ "maxsat": ["wcnf", "rc2", "maxsat"],
48
+ "z3": ["z3"],
49
+ "cpmpy": ["cpmpy"],
50
+ "clingo": ["clingo"],
51
+ "didp": ["didppy"],
52
+ }
53
+
54
+
55
+ def build_config(repo_root: Path, model: str = DEFAULT_MODEL) -> dict:
56
+ """The minion config.json contents for solving via mcp-solver-serve."""
57
+ return {
58
+ "model": {"name": model, "temperature": 0, "max_tokens": 8192},
59
+ "agent": {"max_steps": 30},
60
+ "mcpServers": {
61
+ "mcp-solver": {
62
+ "command": "uv",
63
+ "args": [
64
+ "run",
65
+ "--project",
66
+ str(repo_root),
67
+ "--extra",
68
+ "agent",
69
+ "mcp-solver-serve",
70
+ ],
71
+ }
72
+ },
73
+ }
74
+
75
+
76
+ def write_run_folder(
77
+ dest: Path,
78
+ solver: str,
79
+ problem_text: str,
80
+ repo_root: Path,
81
+ model: str = DEFAULT_MODEL,
82
+ ) -> Path:
83
+ """Write config.json + task.md into *dest* (created if needed).
84
+
85
+ Only these two files are written; run logs and submissions already in
86
+ the folder are left untouched.
87
+ """
88
+ dest.mkdir(parents=True, exist_ok=True)
89
+ config = build_config(repo_root, model)
90
+ (dest / "config.json").write_text(
91
+ json.dumps(config, indent=2) + "\n", encoding="utf-8"
92
+ )
93
+ (dest / "task.md").write_text(
94
+ PREAMBLE.format(solver=solver) + problem_text, encoding="utf-8"
95
+ )
96
+ return dest
97
+
98
+
99
+ def main(argv: list[str] | None = None) -> int:
100
+ parser = argparse.ArgumentParser(
101
+ prog="mcp-solver-runfolder",
102
+ description="Write an mcp-minion run folder for a benchmark problem.",
103
+ )
104
+ parser.add_argument("solver", choices=SOLVERS, help="backend to solve with")
105
+ parser.add_argument(
106
+ "problem",
107
+ help="problem name (a .md stem under tests/problems/<solver>/),"
108
+ " or 'list' to show what is available",
109
+ )
110
+ parser.add_argument(
111
+ "dest",
112
+ nargs="?",
113
+ default=None,
114
+ help="run folder to create (required unless problem is 'list')",
115
+ )
116
+ parser.add_argument(
117
+ "--model",
118
+ default=DEFAULT_MODEL,
119
+ help="agent model, full OpenRouter ID (default: %(default)s)",
120
+ )
121
+ args = parser.parse_args(argv)
122
+
123
+ root = _local_checkout()
124
+ if root is None:
125
+ parser.error(
126
+ "requires a source checkout of mcp-solver"
127
+ " (benchmark problems are not shipped as package data)"
128
+ )
129
+ problems_dir = Path(root) / "tests" / "problems" / args.solver
130
+ available = sorted(p.stem for p in problems_dir.glob("*.md") if p.name != "test.md")
131
+
132
+ if args.problem == "list":
133
+ for name in available:
134
+ print(name)
135
+ return 0
136
+
137
+ if args.dest is None:
138
+ parser.error("dest is required (or use 'list' to show problems)")
139
+ problem_md = problems_dir / f"{args.problem}.md"
140
+ if not problem_md.is_file():
141
+ parser.error(
142
+ f"no problem {args.problem!r} for {args.solver};"
143
+ f" available: {', '.join(available) or '(none)'}"
144
+ )
145
+
146
+ folder = write_run_folder(
147
+ Path(args.dest).expanduser(),
148
+ args.solver,
149
+ problem_md.read_text(encoding="utf-8"),
150
+ Path(root),
151
+ model=args.model,
152
+ )
153
+ print(f"run folder ready: {folder}")
154
+ print(f"run it: uv run --project {root} --extra agent mcp-minion -v {folder}")
155
+ return 0
156
+
157
+
158
+ if __name__ == "__main__":
159
+ sys.exit(main())
@@ -0,0 +1,7 @@
1
+ """Solver helper library, importable inside solve-time kernels.
2
+
3
+ Submodules are imported explicitly by the agent (``from mcp_solver.helpers
4
+ import pysat``), never eagerly here: each submodule requires its solver
5
+ package (python-sat, z3-solver), which is only present in kernels launched
6
+ with the matching ``--with`` list.
7
+ """