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,319 @@
1
+ """
2
+ A minimal MCP-over-HTTP client for run-side tooling.
3
+
4
+ Lifted from ``dev-tools/experiments/runners/marcus_client.py`` in the Marcus
5
+ repository, which the Minecraft plumbing loaded by absolute file path from two
6
+ scripts. The transport (``urllib``) lives in :class:`MarcusMCPClient`; the SSE
7
+ envelope parsing is pure and unit-testable without a server.
8
+
9
+ Two additions. :meth:`MarcusMCPClient.call` raises instead of returning
10
+ ``None``, for callers that must read back every reply body rather than infer
11
+ success from silence. And the handshake speaks the current protocol: it offers
12
+ version 2025-03-26 (what the Node client sends), keeps the version the server
13
+ answers with, sends it as ``MCP-Protocol-Version`` on every later request, and
14
+ records why a connect or call failed in ``last_error``. The first CI run of the
15
+ live check failed at connect against a server built on a newer ``mcp`` package
16
+ than the development machine's, and said nothing about why.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import urllib.error
23
+ import urllib.parse
24
+ import urllib.request
25
+ from typing import Any
26
+
27
+ _DEFAULT_MARCUS_URL = "http://127.0.0.1:4298/mcp/"
28
+ #: The protocol version offered on initialize; the server answers with its own.
29
+ PROTOCOL_VERSION = "2025-03-26"
30
+
31
+
32
+ class MarcusClientError(RuntimeError):
33
+ """A tool call that produced no readable reply."""
34
+
35
+
36
+ def parse_mcp_envelopes(raw: bytes) -> list[dict[str, Any]]:
37
+ """
38
+ Read every JSON-RPC envelope out of an SSE or plain-JSON response body.
39
+
40
+ Parameters
41
+ ----------
42
+ raw : bytes
43
+ The raw HTTP response body.
44
+
45
+ Returns
46
+ -------
47
+ List[Dict[str, Any]]
48
+ The envelopes in order; empty when none parse.
49
+ """
50
+ text = raw.decode(errors="replace")
51
+ envelopes: list[dict[str, Any]] = []
52
+ saw_data = False
53
+ for line in text.splitlines():
54
+ if not line.startswith("data:"):
55
+ continue
56
+ saw_data = True
57
+ try:
58
+ parsed = json.loads(line[len("data:") :].strip())
59
+ except json.JSONDecodeError:
60
+ continue
61
+ if isinstance(parsed, dict):
62
+ envelopes.append(parsed)
63
+ if not saw_data and text.strip().startswith("{"):
64
+ try:
65
+ parsed = json.loads(text)
66
+ except json.JSONDecodeError:
67
+ return envelopes
68
+ if isinstance(parsed, dict):
69
+ envelopes.append(parsed)
70
+ return envelopes
71
+
72
+
73
+ def parse_mcp_tool_result(raw: bytes) -> dict[str, Any] | None:
74
+ """
75
+ Parse an MCP ``tools/call`` HTTP response into its structured result.
76
+
77
+ The tool's own return value sits at ``result.structuredContent.result``.
78
+
79
+ Parameters
80
+ ----------
81
+ raw : bytes
82
+ The raw HTTP response body.
83
+
84
+ Returns
85
+ -------
86
+ Optional[Dict[str, Any]]
87
+ The tool's structured result, or ``None`` if none is present.
88
+ """
89
+ for envelope in parse_mcp_envelopes(raw):
90
+ result = envelope.get("result")
91
+ if not isinstance(result, dict):
92
+ continue
93
+ structured = result.get("structuredContent")
94
+ if isinstance(structured, dict):
95
+ inner = structured.get("result")
96
+ if isinstance(inner, dict):
97
+ return inner
98
+ return None
99
+
100
+
101
+ class MarcusMCPClient:
102
+ """
103
+ Reusable MCP-over-HTTP client for one Marcus server.
104
+
105
+ Call :meth:`connect` once, then :meth:`call_tool` or :meth:`call` as often
106
+ as needed; the session id from the handshake is reused.
107
+
108
+ Parameters
109
+ ----------
110
+ marcus_url : str
111
+ The Marcus MCP HTTP endpoint. A trailing slash is forced: a POST to
112
+ ``/mcp`` without one gets a 307 that ``urllib`` will not replay.
113
+ timeout : float
114
+ Per-request timeout in seconds.
115
+ """
116
+
117
+ def __init__(
118
+ self, marcus_url: str = _DEFAULT_MARCUS_URL, timeout: float = 10.0
119
+ ) -> None:
120
+ self._url = marcus_url.rstrip("/") + "/"
121
+ self._timeout = timeout
122
+ self._session_id: str = ""
123
+ self._protocol_version: str = ""
124
+ self._request_id = 0
125
+ #: Why the last connect or call failed, for a record that must say so.
126
+ self.last_error: str = ""
127
+
128
+ @property
129
+ def url(self) -> str:
130
+ """Return the endpoint this client posts to."""
131
+ return self._url
132
+
133
+ @property
134
+ def connected(self) -> bool:
135
+ """Return True once :meth:`connect` has established a session."""
136
+ return bool(self._session_id)
137
+
138
+ @property
139
+ def protocol_version(self) -> str:
140
+ """Return the protocol version the server answered with, once connected."""
141
+ return self._protocol_version
142
+
143
+ def _headers(self) -> dict[str, str]:
144
+ headers = {
145
+ "Content-Type": "application/json",
146
+ "Accept": "application/json, text/event-stream",
147
+ }
148
+ if self._session_id:
149
+ headers["mcp-session-id"] = self._session_id
150
+ if self._protocol_version:
151
+ headers["MCP-Protocol-Version"] = self._protocol_version
152
+ return headers
153
+
154
+ def _send(self, payload: dict[str, Any]) -> tuple[str, bytes]:
155
+ """
156
+ POST once, following a 307 or 308 with the same method and body.
157
+
158
+ Older servers redirect ``/mcp`` to ``/mcp/``; newer ones redirect the other
159
+ way. ``urllib`` refuses to replay a POST through a redirect, so it is done
160
+ here, and the endpoint the server named is kept for every later call.
161
+
162
+ Parameters
163
+ ----------
164
+ payload : dict[str, Any]
165
+ The JSON-RPC envelope.
166
+
167
+ Returns
168
+ -------
169
+ tuple[str, bytes]
170
+ The ``mcp-session-id`` header (``""`` when absent) and the body.
171
+ """
172
+ for _ in range(4):
173
+ req = urllib.request.Request(
174
+ self._url,
175
+ data=json.dumps(payload).encode(),
176
+ headers=self._headers(),
177
+ method="POST",
178
+ )
179
+ try:
180
+ with urllib.request.urlopen(
181
+ req, timeout=self._timeout
182
+ ) as resp: # nosec B310
183
+ session = resp.headers.get("mcp-session-id", "") or ""
184
+ body: bytes = resp.read()
185
+ return session, body
186
+ except urllib.error.HTTPError as exc:
187
+ location = exc.headers.get("Location") if exc.headers else None
188
+ if exc.code in (307, 308) and location:
189
+ self._url = urllib.parse.urljoin(self._url, location)
190
+ continue
191
+ raise
192
+ raise urllib.error.URLError("too many redirects from the MCP endpoint")
193
+
194
+ def _post(self, payload: dict[str, Any]) -> bytes:
195
+ return self._send(payload)[1]
196
+
197
+ def connect(self) -> bool:
198
+ """
199
+ Perform the MCP handshake and store the session id and protocol version.
200
+
201
+ Returns
202
+ -------
203
+ bool
204
+ True if a session was established; on False, ``last_error`` says why.
205
+ """
206
+ init_payload = {
207
+ "jsonrpc": "2.0",
208
+ "id": 1,
209
+ "method": "initialize",
210
+ "params": {
211
+ "protocolVersion": PROTOCOL_VERSION,
212
+ "capabilities": {},
213
+ "clientInfo": {"name": "marcus-toolkit", "version": "0.1"},
214
+ },
215
+ }
216
+ self._session_id = ""
217
+ self._protocol_version = ""
218
+ try:
219
+ session, body = self._send(init_payload)
220
+ for envelope in parse_mcp_envelopes(body):
221
+ if envelope.get("error"):
222
+ self.last_error = f"initialize refused: {envelope['error']}"
223
+ return False
224
+ result = envelope.get("result")
225
+ if isinstance(result, dict) and result.get("protocolVersion"):
226
+ self._protocol_version = str(result["protocolVersion"])
227
+ if not session:
228
+ self.last_error = "initialize reply carried no mcp-session-id header"
229
+ return False
230
+ self._session_id = session
231
+ if not self._protocol_version:
232
+ self._protocol_version = PROTOCOL_VERSION
233
+ self._post(
234
+ {"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}}
235
+ )
236
+ self.last_error = ""
237
+ return True
238
+ except urllib.error.HTTPError as exc:
239
+ detail = exc.read()[:300].decode(errors="replace")
240
+ self.last_error = f"HTTP {exc.code} from {self._url}: {detail}"
241
+ except (urllib.error.URLError, OSError, json.JSONDecodeError) as exc:
242
+ self.last_error = f"{type(exc).__name__}: {exc}"
243
+ self._session_id = ""
244
+ return False
245
+
246
+ def call_tool(
247
+ self, name: str, arguments: dict[str, Any] | None = None
248
+ ) -> dict[str, Any] | None:
249
+ """
250
+ Call a tool and return its structured result, or ``None`` on any failure.
251
+
252
+ Parameters
253
+ ----------
254
+ name : str
255
+ The tool name.
256
+ arguments : Optional[Dict[str, Any]]
257
+ The tool arguments.
258
+
259
+ Returns
260
+ -------
261
+ Optional[Dict[str, Any]]
262
+ The reply body, or ``None`` when not connected, the request failed,
263
+ or the response carried no structured result; ``last_error`` says why.
264
+ """
265
+ if not self._session_id:
266
+ self.last_error = f"{name}: not connected"
267
+ return None
268
+ self._request_id += 1
269
+ try:
270
+ raw = self._post(
271
+ {
272
+ "jsonrpc": "2.0",
273
+ "id": self._request_id,
274
+ "method": "tools/call",
275
+ "params": {"name": name, "arguments": arguments or {}},
276
+ }
277
+ )
278
+ except urllib.error.HTTPError as exc:
279
+ detail = exc.read()[:300].decode(errors="replace")
280
+ self.last_error = f"{name}: HTTP {exc.code}: {detail}"
281
+ return None
282
+ except (urllib.error.URLError, OSError) as exc:
283
+ self.last_error = f"{name}: {type(exc).__name__}: {exc}"
284
+ return None
285
+ result = parse_mcp_tool_result(raw)
286
+ if result is None:
287
+ self.last_error = f"{name}: no structured result in the reply"
288
+ return result
289
+
290
+ def call(
291
+ self, name: str, arguments: dict[str, Any] | None = None
292
+ ) -> dict[str, Any]:
293
+ """
294
+ Call a tool and return its reply body, raising when there is none.
295
+
296
+ Parameters
297
+ ----------
298
+ name : str
299
+ The tool name.
300
+ arguments : Optional[Dict[str, Any]]
301
+ The tool arguments.
302
+
303
+ Returns
304
+ -------
305
+ Dict[str, Any]
306
+ The reply body.
307
+
308
+ Raises
309
+ ------
310
+ MarcusClientError
311
+ When the client is not connected or the call produced no readable
312
+ reply. The message names the tool and the reason.
313
+ """
314
+ if not self._session_id:
315
+ raise MarcusClientError(f"{name}: not connected")
316
+ reply = self.call_tool(name, arguments)
317
+ if reply is None:
318
+ raise MarcusClientError(f"{name}: {self.last_error or 'no readable reply'}")
319
+ return reply
@@ -0,0 +1,154 @@
1
+ """The candidate funnel, read from ``candidates.jsonl``, never reconstructed."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections import Counter
7
+ from collections.abc import Iterable
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from .views import candidate_views
12
+
13
+
14
+ def candidate_funnel(
15
+ path: Path | None,
16
+ calls: Iterable[dict[str, Any]] | None = None,
17
+ planned: Path | None = None,
18
+ ) -> dict[str, Any]:
19
+ """
20
+ Summarise the candidate funnel from the controller's ``candidates.jsonl``.
21
+
22
+ Parameters
23
+ ----------
24
+ path : Optional[Path]
25
+ ``candidates.jsonl``; missing means candidates were off.
26
+ calls : Optional[Iterable[Dict[str, Any]]]
27
+ The agents' board calls, for the view log; with it, evaporations split
28
+ into ``evaporated_declined`` and ``evaporated_unseen``.
29
+ planned : Optional[Path]
30
+ A planner's ``planned.jsonl`` whose passes record ``candidates_posted``.
31
+
32
+ Returns
33
+ -------
34
+ Dict[str, Any]
35
+ Counts, per-candidate view numbers, the promotions, and the raw events.
36
+ """
37
+ empty: dict[str, Any] = {
38
+ "promoted": 0,
39
+ "evaporated": 0,
40
+ "open_at_end": 0,
41
+ "shown_to": {},
42
+ "declined_by": {},
43
+ "awaiting_turn": {},
44
+ "abstained_by": {},
45
+ "abstentions": 0,
46
+ "promotions": [],
47
+ "evaporated_declined": 0,
48
+ "evaporated_unseen": 0,
49
+ "posted_by_planner": 0,
50
+ "posted_from_proposals": 0,
51
+ "posted_from_observations": 0,
52
+ "posted_by_coordinator": 0,
53
+ "objections": 0,
54
+ "mean_seconds_to_promotion": None,
55
+ "supporters": {},
56
+ "views": {},
57
+ "events": [],
58
+ }
59
+ by_planner: set[str] = set()
60
+ if planned is not None and planned.exists():
61
+ for line in planned.read_text(errors="replace").splitlines():
62
+ try:
63
+ record = json.loads(line)
64
+ except json.JSONDecodeError:
65
+ continue
66
+ for subject in record.get("candidates_posted") or []:
67
+ by_planner.add(str(subject))
68
+ if path is None or not path.exists():
69
+ return {**empty, "posted_by_planner": len(by_planner)}
70
+ events: list[dict[str, Any]] = []
71
+ for line in path.read_text(errors="replace").splitlines():
72
+ try:
73
+ events.append(json.loads(line))
74
+ except json.JSONDecodeError:
75
+ continue
76
+ promoted = [e for e in events if e.get("event") == "promoted"]
77
+ evaporated = [e for e in events if e.get("event") == "evaporated"]
78
+ open_at_end = [e for e in events if e.get("event") == "open_at_end"]
79
+ posted_all = [e for e in events if e.get("event") == "posted"]
80
+ observed = [e for e in posted_all if e.get("observers")]
81
+ coordinator = [e for e in posted_all if e.get("from") == "coordinator"]
82
+ proposals = [
83
+ e
84
+ for e in posted_all
85
+ if not e.get("observers") and e.get("from") != "coordinator"
86
+ ]
87
+ supporters: Counter[str] = Counter()
88
+ for e in promoted:
89
+ for agent in e.get("supporters") or []:
90
+ supporters[agent] += 1
91
+ times = [
92
+ int(e["seconds_to_promotion"])
93
+ for e in promoted
94
+ if e.get("seconds_to_promotion") is not None
95
+ ]
96
+ closed_at = {
97
+ str(e.get("subject")): str(e.get("at"))
98
+ for e in events
99
+ if e.get("event") in ("promoted", "evaporated")
100
+ and e.get("subject")
101
+ and e.get("at")
102
+ }
103
+ seen_by = (
104
+ candidate_views(list(calls), before=closed_at) if calls is not None else {}
105
+ )
106
+ subjects = {str(e.get("subject")) for e in events if e.get("subject")} | by_planner
107
+ views = {s: len(seen_by.get(s, [])) for s in sorted(subjects)}
108
+ declined = sum(1 for e in evaporated if views.get(str(e.get("subject")), 0) > 0)
109
+ closed = promoted + evaporated + open_at_end
110
+
111
+ def per(key: str) -> dict[str, int]:
112
+ return {
113
+ str(e.get("subject")): len(e.get(key) or [])
114
+ for e in closed
115
+ if e.get("subject")
116
+ }
117
+
118
+ return {
119
+ "promoted": len(promoted),
120
+ "evaporated": len(evaporated),
121
+ "open_at_end": len(open_at_end),
122
+ "shown_to": per("shown_to"),
123
+ "declined_by": per("declined_by"),
124
+ "awaiting_turn": per("awaiting_turn"),
125
+ "abstained_by": per("abstained_by"),
126
+ "abstentions": sum(
127
+ len(e.get("agents") or []) for e in events if e.get("event") == "abstained"
128
+ ),
129
+ "promotions": [
130
+ {
131
+ "name": e.get("name"),
132
+ "subject": e.get("subject"),
133
+ "supporters": list(e.get("supporters") or []),
134
+ "objectors": list(e.get("objectors") or []),
135
+ "seconds_to_promotion": e.get("seconds_to_promotion"),
136
+ "shown_to": len(e.get("shown_to") or []),
137
+ "declined_by": len(e.get("declined_by") or []),
138
+ "abstained_by": len(e.get("abstained_by") or []),
139
+ "task_ids": list(e.get("task_ids") or []),
140
+ }
141
+ for e in promoted
142
+ ],
143
+ "evaporated_declined": declined,
144
+ "evaporated_unseen": len(evaporated) - declined,
145
+ "views": views,
146
+ "posted_by_planner": len(by_planner),
147
+ "posted_from_proposals": len(proposals),
148
+ "posted_from_observations": len(observed),
149
+ "posted_by_coordinator": len(coordinator),
150
+ "objections": sum(1 for e in promoted + evaporated if e.get("objectors")),
151
+ "mean_seconds_to_promotion": round(sum(times) / len(times)) if times else None,
152
+ "supporters": dict(sorted(supporters.items())),
153
+ "events": events,
154
+ }
marcus_toolkit/gate.py ADDED
@@ -0,0 +1,150 @@
1
+ """
2
+ Does the agreement chain still work? Asked of a run's record, in three links.
3
+
4
+ Work is agreed before it is created: the coordinator posts a candidate; agents
5
+ answer it; two supporters promote it into a task an agent then picks up. Three
6
+ links, each needing the one before it: a candidate is posted at all; support
7
+ is recorded against a candidate; a promotion creates a task on the board. The
8
+ verdict is one of four, and PARTIAL is a real outcome rather than a softened
9
+ failure: PROVEN, PARTIAL (posted and supported, nobody promoted, an agent
10
+ choice), FAILED (a link that does not depend on an agent's choice did not fire,
11
+ or a promotion created nothing), NOT PROVEN (nothing ran).
12
+
13
+ Lifted from the Minecraft plumbing's ``live_check_no_regression.py``, the
14
+ reading half; starting a run is the extension's business.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import sqlite3
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from .measure import read_jsonl
24
+
25
+ PROVEN, FAILED, NOT_PROVEN, PARTIAL = 0, 1, 2, 3
26
+ NAMES = {
27
+ PROVEN: "PROVEN",
28
+ FAILED: "FAILED",
29
+ NOT_PROVEN: "NOT PROVEN",
30
+ PARTIAL: "PARTIAL",
31
+ }
32
+
33
+
34
+ def task_exists(kanban_db: Path, task_id: str) -> bool | None:
35
+ """Return whether a task with this id is on the board, or ``None`` if unreadable."""
36
+ if not kanban_db.exists():
37
+ return None
38
+ con = sqlite3.connect(f"file:{kanban_db}?mode=ro", uri=True)
39
+ try:
40
+ row = con.execute(
41
+ "select count(*) from tasks where id = ?", (task_id,)
42
+ ).fetchone()
43
+ return bool(row and row[0])
44
+ except sqlite3.Error:
45
+ return None
46
+ finally:
47
+ con.close()
48
+
49
+
50
+ def check_chain(
51
+ candidates_jsonl: Path, kanban_db: Path, planned_jsonl: Path | None = None
52
+ ) -> dict[str, Any]:
53
+ """
54
+ Report which links of the chain fired.
55
+
56
+ Parameters
57
+ ----------
58
+ candidates_jsonl : Path
59
+ The controller's event stream.
60
+ kanban_db : Path
61
+ The run's own board, to confirm each promoted task id.
62
+ planned_jsonl : Optional[Path]
63
+ A planner's passes, whose ``candidates_posted`` count as postings.
64
+
65
+ Returns
66
+ -------
67
+ Dict[str, Any]
68
+ ``code`` (0 proven, 3 partial, 1 failed, 2 not proven), ``verdict``,
69
+ the three link counts, and ``lines`` explaining the reading.
70
+ """
71
+ events = read_jsonl(candidates_jsonl)
72
+ passes = read_jsonl(planned_jsonl) if planned_jsonl else []
73
+ by_planner: set[str] = set()
74
+ for record in passes:
75
+ for subject in record.get("candidates_posted") or []:
76
+ by_planner.add(str(subject))
77
+ # A candidate that closed, failed or stayed open was necessarily posted, so
78
+ # link 1 counts every subject any candidate event names, not only the
79
+ # "posted" events a planning pass may record elsewhere.
80
+ candidate_events = {
81
+ "posted",
82
+ "promoted",
83
+ "evaporated",
84
+ "open_at_end",
85
+ "promotion_failed",
86
+ "promotion_abandoned",
87
+ "abstained",
88
+ "already_on_board",
89
+ }
90
+ posted = {
91
+ str(e.get("subject"))
92
+ for e in events
93
+ if e.get("event") in candidate_events and e.get("subject")
94
+ } | by_planner
95
+ promoted = [e for e in events if e.get("event") == "promoted"]
96
+ failed = [e for e in events if e.get("event") == "promotion_failed"]
97
+ supported = [e for e in events if (e.get("supporters") or [])]
98
+ lines: list[str] = [
99
+ f"LINK 1, candidates posted: {len(posted)}",
100
+ f"LINK 2, candidates with support recorded: {len(supported)}",
101
+ f"LINK 3, promotions: {len(promoted)} ({len(failed)} refused by the board)",
102
+ ]
103
+ out: dict[str, Any] = {
104
+ "posted": len(posted),
105
+ "supported": len(supported),
106
+ "promoted": len(promoted),
107
+ "refused": len(failed),
108
+ "lines": lines,
109
+ }
110
+
111
+ def finish(code: int, reason: str) -> dict[str, Any]:
112
+ lines.append(f"VERDICT: {NAMES[code]} -- {reason}")
113
+ return {**out, "code": code, "verdict": NAMES[code]}
114
+
115
+ if not events and not passes:
116
+ return finish(NOT_PROVEN, "nothing was tended, so no link was exercised.")
117
+ if not posted:
118
+ return finish(
119
+ FAILED,
120
+ "no candidate was posted; posting does not depend on an agent's choice.",
121
+ )
122
+ for event in promoted:
123
+ ids = [str(i) for i in (event.get("task_ids") or [])]
124
+ if not ids:
125
+ return finish(FAILED, "a promotion created nothing.")
126
+ for task_id in ids:
127
+ if task_exists(kanban_db, task_id) is not True:
128
+ return finish(
129
+ FAILED,
130
+ f"promoted task {task_id[:12]} is not on the run's own board.",
131
+ )
132
+ lines.append(f" promoted {str(event.get('name'))[:60]}: task_ids {ids}")
133
+ if failed and not promoted:
134
+ return finish(FAILED, "every promotion was refused by the board.")
135
+ if not promoted:
136
+ if not supported:
137
+ return finish(
138
+ PARTIAL,
139
+ "candidates posted and no agent answered any; link 2 unexercised.",
140
+ )
141
+ return finish(
142
+ PARTIAL,
143
+ "support was recorded and no candidate reached promotion;"
144
+ " link 3 unexercised.",
145
+ )
146
+ return finish(
147
+ PROVEN,
148
+ "candidates posted, support recorded, and a promotion created a task"
149
+ " on the board.",
150
+ )