marcus-toolkit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,348 @@
1
+ """
2
+ A Marcus server under test: private, model-free, and provably this run's.
3
+
4
+ What every extension's run script did by hand, once:
5
+
6
+ * a config with the SQLite provider, the model switched off and an empty key
7
+ (a placeholder key still fires one real call on startup: issue #905 and
8
+ ``~/marcus-experiments/numeric-verifier/RESULTS.md``, "Incident"), the
9
+ context and events features on (opinions and decisions need them), memory
10
+ and visibility off, and its own HTTP port;
11
+ * an environment with every model-provider key stripped, its own
12
+ ``MARCUS_DATA_DIR`` and ``MARCUS_CONFIG``, telemetry off, and
13
+ ``PYTHONPATH`` at the pinned checkout;
14
+ * a refusal to start on a port someone else holds, a readiness probe, a
15
+ check that the port's owner is this run's server, and a pid file;
16
+ * a count of model-provider hosts in the server log afterwards, which must be zero.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import os
23
+ import socket
24
+ import subprocess # nosec B404 - starting our own server, fixed argv
25
+ import time
26
+ import urllib.error
27
+ import urllib.request
28
+ from pathlib import Path
29
+
30
+ #: Keys stripped from the server's environment, so the server can make no paid call.
31
+ STRIPPED_KEYS = (
32
+ "ANTHROPIC_API_KEY",
33
+ "CLAUDE_API_KEY",
34
+ "OPENAI_API_KEY",
35
+ "GEMINI_API_KEY",
36
+ "PERPLEXITY_API_KEY",
37
+ )
38
+ #: Hosts whose appearance in a server log means a model-provider call was made.
39
+ MODEL_HOSTS = (
40
+ "api.anthropic.com",
41
+ "api.openai.com",
42
+ "generativelanguage.googleapis.com",
43
+ "api.perplexity.ai",
44
+ )
45
+
46
+
47
+ def free_port() -> int:
48
+ """Return a TCP port nothing is listening on right now."""
49
+ with socket.socket() as sock:
50
+ sock.bind(("127.0.0.1", 0))
51
+ return int(sock.getsockname()[1])
52
+
53
+
54
+ def port_in_use(port: int, host: str = "127.0.0.1") -> bool:
55
+ """Whether something accepts connections on ``host:port``."""
56
+ with socket.socket() as sock:
57
+ sock.settimeout(0.2)
58
+ return sock.connect_ex((host, port)) == 0
59
+
60
+
61
+ def port_owner(port: int) -> int | None:
62
+ """Return the pid listening on ``port``, by ``lsof``, or ``None``."""
63
+ try:
64
+ out = subprocess.run( # nosec B603 B607
65
+ ["lsof", "-t", "-nP", f"-iTCP:{port}", "-sTCP:LISTEN"],
66
+ capture_output=True,
67
+ text=True,
68
+ timeout=5,
69
+ ).stdout.split()
70
+ except (OSError, subprocess.SubprocessError):
71
+ return None
72
+ return int(out[0]) if out else None
73
+
74
+
75
+ def command_of(pid: int) -> str:
76
+ """Return the command line of ``pid``, or an empty string."""
77
+ try:
78
+ return subprocess.run(
79
+ ["ps", "-o", "command=", "-p", str(pid)],
80
+ capture_output=True,
81
+ text=True,
82
+ timeout=5,
83
+ ).stdout.strip() # nosec B603 B607
84
+ except (OSError, subprocess.SubprocessError):
85
+ return ""
86
+
87
+
88
+ def write_config(
89
+ path: Path,
90
+ *,
91
+ port: int,
92
+ project_name: str,
93
+ sqlite_db_path: Path | None = None,
94
+ host: str = "127.0.0.1",
95
+ single_project_mode: bool = False,
96
+ ) -> dict[str, object]:
97
+ """
98
+ Write the server's config: SQLite, model off with an empty key, own port.
99
+
100
+ Parameters
101
+ ----------
102
+ path : Path
103
+ Where to write the JSON.
104
+ port : int
105
+ The HTTP port.
106
+ project_name : str
107
+ The default project name the server starts with.
108
+ sqlite_db_path : Optional[Path]
109
+ The board file; when omitted the data-directory resolver decides (issue #792).
110
+ host : str
111
+ The bind address.
112
+ single_project_mode : bool
113
+ ``False`` lets one server hold several projects (ADR-0013 K6).
114
+
115
+ Returns
116
+ -------
117
+ Dict[str, object]
118
+ The config written.
119
+ """
120
+ # The server runs from the checkout, so every path it is given must be absolute.
121
+ kanban: dict[str, object] = {"provider": "sqlite"}
122
+ if sqlite_db_path is not None:
123
+ kanban["sqlite_db_path"] = str(Path(sqlite_db_path).resolve())
124
+ config: dict[str, object] = {
125
+ "kanban": kanban,
126
+ "default_project_name": f"{project_name} - Main Board",
127
+ "project_name": project_name,
128
+ "single_project_mode": single_project_mode,
129
+ "auto_find_board": False,
130
+ "ai": {"provider": "anthropic", "enabled": False, "anthropic_api_key": ""},
131
+ # The context feature is what opinions, decisions and task context ride
132
+ # on; without it every give_opinion is refused with "Context system not
133
+ # enabled" and every opinions read is empty (found by the wargame's
134
+ # deliberation rehearsal, 2026-11-11). Events feed the record. Memory
135
+ # and visibility stay off: nothing a run needs, and memory is a model path.
136
+ "features": {
137
+ "events": True,
138
+ "context": True,
139
+ "memory": False,
140
+ "visibility": False,
141
+ },
142
+ "transport": {
143
+ "type": "http",
144
+ "http_host": host,
145
+ "http_port": port,
146
+ "http_path": "/mcp",
147
+ "http_enabled": True,
148
+ "dual_mode": False,
149
+ },
150
+ }
151
+ path.parent.mkdir(parents=True, exist_ok=True)
152
+ path.write_text(json.dumps(config, indent=2) + "\n")
153
+ return config
154
+
155
+
156
+ def build_env(
157
+ checkout: Path,
158
+ data_dir: Path,
159
+ config_path: Path,
160
+ base: dict[str, str] | None = None,
161
+ ) -> dict[str, str]:
162
+ """
163
+ Build the server's environment: keys stripped, paths set, telemetry off.
164
+
165
+ Parameters
166
+ ----------
167
+ checkout : Path
168
+ The pinned Marcus checkout, which becomes ``PYTHONPATH``.
169
+ data_dir : Path
170
+ ``MARCUS_DATA_DIR``.
171
+ config_path : Path
172
+ ``MARCUS_CONFIG``.
173
+ base : Optional[Dict[str, str]]
174
+ The environment to start from; the process environment by default.
175
+
176
+ Returns
177
+ -------
178
+ Dict[str, str]
179
+ """
180
+ env = {
181
+ k: v
182
+ for k, v in (base if base is not None else os.environ).items()
183
+ if k not in STRIPPED_KEYS
184
+ }
185
+ env["MARCUS_DATA_DIR"] = str(Path(data_dir).resolve())
186
+ env["MARCUS_CONFIG"] = str(Path(config_path).resolve())
187
+ env["MARCUS_TELEMETRY"] = "off"
188
+ env["PYTHONPATH"] = str(Path(checkout).resolve())
189
+ return env
190
+
191
+
192
+ def model_calls_in_log(log_path: Path) -> int:
193
+ """Count lines of the server log that name a model-provider host."""
194
+ if not log_path.is_file():
195
+ return 0
196
+ text = log_path.read_text(errors="replace")
197
+ return sum(
198
+ line.count(h) > 0
199
+ for line in text.splitlines()
200
+ for h in MODEL_HOSTS
201
+ if h in line
202
+ )
203
+
204
+
205
+ class ServerUnderTest:
206
+ """
207
+ Start, probe and stop one private Marcus server.
208
+
209
+ Parameters
210
+ ----------
211
+ checkout : Path
212
+ The pinned Marcus checkout to serve from (its ``src`` is importable).
213
+ data_dir : Path
214
+ The run's own ``MARCUS_DATA_DIR``.
215
+ config_path : Path
216
+ The config written by :func:`write_config`.
217
+ port : int
218
+ The HTTP port in that config.
219
+ log_path : Path
220
+ Where the server's stdout and stderr go.
221
+ python : str
222
+ The interpreter to run ``src.marcus_mcp.server`` with.
223
+ """
224
+
225
+ def __init__(
226
+ self,
227
+ checkout: Path,
228
+ data_dir: Path,
229
+ config_path: Path,
230
+ port: int,
231
+ log_path: Path,
232
+ python: str = "python",
233
+ ) -> None:
234
+ self.checkout = checkout
235
+ self.data_dir = data_dir
236
+ self.config_path = config_path
237
+ self.port = port
238
+ self.log_path = log_path
239
+ self.python = python
240
+ self.process: subprocess.Popen[bytes] | None = None
241
+
242
+ @property
243
+ def url(self) -> str:
244
+ """The MCP endpoint."""
245
+ return f"http://127.0.0.1:{self.port}/mcp/"
246
+
247
+ @property
248
+ def pid_file(self) -> Path:
249
+ """Where the pid is written for the watchdog."""
250
+ return self.data_dir / "server.pid"
251
+
252
+ def start(self, wait_seconds: float = 60.0) -> int:
253
+ """
254
+ Start the server and wait until it answers and the port is ours.
255
+
256
+ Parameters
257
+ ----------
258
+ wait_seconds : float
259
+ How long to wait for readiness.
260
+
261
+ Returns
262
+ -------
263
+ int
264
+ The server's pid.
265
+
266
+ Raises
267
+ ------
268
+ RuntimeError
269
+ If the port is held by someone else, the server never answers, or
270
+ the port's owner is not a Marcus server.
271
+ """
272
+ if port_in_use(self.port):
273
+ raise RuntimeError(
274
+ f"port {self.port} is already in use; refusing to run against"
275
+ " another session's Marcus"
276
+ )
277
+ self.data_dir.mkdir(parents=True, exist_ok=True)
278
+ self.log_path.parent.mkdir(parents=True, exist_ok=True)
279
+ env = build_env(self.checkout, self.data_dir, self.config_path)
280
+ with self.log_path.open("ab") as log:
281
+ self.process = subprocess.Popen( # nosec B603
282
+ [self.python, "-m", "src.marcus_mcp.server", "--http", "--all-tools"],
283
+ cwd=str(self.checkout),
284
+ env=env,
285
+ stdout=log,
286
+ stderr=subprocess.STDOUT,
287
+ )
288
+ self.pid_file.parent.mkdir(parents=True, exist_ok=True)
289
+ self.pid_file.write_text(f"{self.process.pid}\n")
290
+ deadline = time.time() + wait_seconds
291
+ while time.time() < deadline:
292
+ if self.process.poll() is not None:
293
+ raise RuntimeError(
294
+ f"the server exited with {self.process.returncode};"
295
+ f" see {self.log_path}"
296
+ )
297
+ if self._answers():
298
+ break
299
+ time.sleep(0.5)
300
+ else:
301
+ self.stop()
302
+ raise RuntimeError(
303
+ f"the server never answered on {self.url} within {wait_seconds}s;"
304
+ f" see {self.log_path}"
305
+ )
306
+ owner = port_owner(self.port)
307
+ if owner is None or "src.marcus_mcp.server" not in command_of(owner):
308
+ self.stop()
309
+ raise RuntimeError(
310
+ f"port {self.port} is not owned by a Marcus server of this run"
311
+ f" (owner {owner})"
312
+ )
313
+ return self.process.pid
314
+
315
+ def _answers(self) -> bool:
316
+ try:
317
+ urllib.request.urlopen(
318
+ self.url, timeout=2
319
+ ) # nosec B310 - our own local server
320
+ return True
321
+ except urllib.error.HTTPError:
322
+ return True
323
+ except (urllib.error.URLError, OSError):
324
+ return False
325
+
326
+ def stop(self, wait_seconds: float = 20.0) -> None:
327
+ """Stop the server and whoever else holds the port, then wait."""
328
+ pids: list[int] = []
329
+ if self.process is not None and self.process.poll() is None:
330
+ pids.append(self.process.pid)
331
+ owner = port_owner(self.port)
332
+ if owner is not None and owner not in pids:
333
+ pids.append(owner)
334
+ for pid in pids:
335
+ try:
336
+ os.kill(pid, 15)
337
+ except OSError:
338
+ pass
339
+ if self.process is not None:
340
+ try:
341
+ self.process.wait(timeout=wait_seconds)
342
+ except subprocess.TimeoutExpired:
343
+ self.process.kill()
344
+ self.process = None
345
+
346
+ def model_calls(self) -> int:
347
+ """How many model-provider lines the log carries; a model-free run has zero."""
348
+ return model_calls_in_log(self.log_path)