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.
- agentic_mcp_solver-4.2.0.dist-info/METADATA +318 -0
- agentic_mcp_solver-4.2.0.dist-info/RECORD +22 -0
- agentic_mcp_solver-4.2.0.dist-info/WHEEL +4 -0
- agentic_mcp_solver-4.2.0.dist-info/entry_points.txt +5 -0
- agentic_mcp_solver-4.2.0.dist-info/licenses/LICENSE +21 -0
- mcp_solver/__init__.py +20 -0
- mcp_solver/agent/__init__.py +1 -0
- mcp_solver/agent/cli.py +496 -0
- mcp_solver/agent/harness.py +258 -0
- mcp_solver/agent/mcp_server.py +508 -0
- mcp_solver/agent/runfolder.py +159 -0
- mcp_solver/helpers/__init__.py +7 -0
- mcp_solver/helpers/maxsat.py +79 -0
- mcp_solver/helpers/pysat.py +346 -0
- mcp_solver/helpers/z3.py +213 -0
- mcp_solver/templates/__init__.py +22 -0
- mcp_solver/templates/clingo.md +1305 -0
- mcp_solver/templates/cpmpy.md +124 -0
- mcp_solver/templates/didp.md +270 -0
- mcp_solver/templates/maxsat.md +192 -0
- mcp_solver/templates/pysat.md +186 -0
- mcp_solver/templates/z3.md +165 -0
|
@@ -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
|
+
"""
|