@wendellresearch-ai/benchwarmers 0.3.0 → 0.3.2

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.
package/mcp_server.py CHANGED
@@ -15,7 +15,8 @@ the verdicts are that server's, whichever machine it is on:
15
15
  BENCHWARMERS_TIMEOUT seconds to wait for a panel, default 900
16
16
 
17
17
  Tools: list_collections (GET /v1/models), ask (POST /v1/chat/completions),
18
- verdict (POST /bench/verdict). A refusal from the server, or no server at
18
+ verdict (POST /bench/verdict), and pending_verdicts (POST /bench/pending) for
19
+ the plugin's Stop hook. A refusal from the server, or no server at
19
20
  all, comes back as a tool error carrying its message. Standard library only,
20
21
  and nothing from metrics/: the data folder is the server's, not this one's.
21
22
  """
@@ -24,6 +25,7 @@ from __future__ import annotations
24
25
 
25
26
  import json
26
27
  import os
28
+ import re
27
29
  import sys
28
30
  import threading
29
31
  import urllib.error
@@ -90,6 +92,16 @@ TOOLS = [
90
92
  "description": "Claims it made that are untrue, one sentence each."}}},
91
93
  "annotations": {"readOnlyHint": False, "destructiveHint": False, "idempotentHint": True,
92
94
  "openWorldHint": False}},
95
+ {"name": "pending_verdicts",
96
+ "title": "Seats still to judge",
97
+ "description": "For the Benchwarmers Claude Code plugin's Stop hook, which calls it at the end of a"
98
+ " turn: the seats of this session's panels that have no verdict yet. You do not need to"
99
+ " call it; send verdicts with `verdict`.",
100
+ "inputSchema": {"type": "object", "additionalProperties": False,
101
+ "properties": {"session": {"type": "string", "description": "The Claude Code session id."},
102
+ "transcript": {"type": "string", "description": "The session's transcript_path."},
103
+ "active": {"description": "The hook's stop_hook_active: true says nothing."}}},
104
+ "annotations": {"readOnlyHint": True, "openWorldHint": False}},
93
105
  ]
94
106
 
95
107
 
@@ -102,11 +114,32 @@ def base_url() -> str:
102
114
  return url.removesuffix("/v1")
103
115
 
104
116
 
105
- def http(method: str, path: str, body: dict | None = None) -> dict:
106
- url = base_url() + path
107
- headers = {"Accept": "application/json"}
108
- if key := (os.environ.get("BENCHWARMERS_KEY") or "").strip():
109
- headers["Authorization"] = f"Bearer {key}"
117
+ # (server, headers for it): where a tool's calls go.
118
+ Conn = tuple[str, dict]
119
+
120
+
121
+ def env_conn() -> Conn:
122
+ """The stdio server's: BENCHWARMERS_URL and BENCHWARMERS_KEY. Served over HTTP
123
+ (gateway.py's /mcp), the server's own address and the caller's credential instead."""
124
+ key = (os.environ.get("BENCHWARMERS_KEY") or "").strip()
125
+ return base_url(), {"Authorization": f"Bearer {key}"} if key else {}
126
+
127
+
128
+ # A seat's call id, as ask returns it (gateway.py: p<date>-<time>-<hex>.<seat>); only ids the
129
+ # call log knows count, so a loose match costs nothing.
130
+ CALL_ID = re.compile(r"\bp\d{8}-\d{6}-[0-9a-f]{4}\.[a-z]+\d*\b")
131
+ TRANSCRIPT_TAIL = 8 * 1024 * 1024 # bytes of a transcript read: its recent panels
132
+ # Per Claude Code session, the seats pending_verdicts already reminded it of. ponytail: in memory
133
+ # and capped, so a restart reminds once more; keep it in the data folder if that ever matters.
134
+ REMINDED: dict[str, set] = {}
135
+ REMINDED_LOCK = threading.Lock()
136
+ MAX_SESSIONS = 1000
137
+
138
+
139
+ def http(conn: Conn, method: str, path: str, body: dict | None = None) -> dict:
140
+ base, extra = conn
141
+ url = base + path
142
+ headers = {"Accept": "application/json", **extra}
110
143
  data = None
111
144
  if body is not None:
112
145
  data = json.dumps(body).encode()
@@ -126,21 +159,21 @@ def http(method: str, path: str, body: dict | None = None) -> dict:
126
159
  except (urllib.error.URLError, ConnectionError) as e:
127
160
  reason = getattr(e, "reason", e)
128
161
  if isinstance(reason, TimeoutError):
129
- raise ToolError(f"no answer from Benchwarmers at {base_url()} in {timeout:.0f} s;"
162
+ raise ToolError(f"no answer from Benchwarmers at {base} in {timeout:.0f} s;"
130
163
  " set BENCHWARMERS_TIMEOUT for longer panels") from None
131
- raise ToolError(f"Benchwarmers is not running at {base_url()} ({reason}). Start it (`benchwarmers`,"
164
+ raise ToolError(f"Benchwarmers is not running at {base} ({reason}). Start it (`benchwarmers`,"
132
165
  " or its service), or point BENCHWARMERS_URL at the server you use.") from None
133
166
  except TimeoutError:
134
- raise ToolError(f"no answer from Benchwarmers at {base_url()} in {timeout:.0f} s;"
167
+ raise ToolError(f"no answer from Benchwarmers at {base} in {timeout:.0f} s;"
135
168
  " set BENCHWARMERS_TIMEOUT for longer panels") from None
136
169
  except ValueError:
137
- raise ToolError(f"{base_url()} answered {method} {path} with something that is not JSON;"
170
+ raise ToolError(f"{base} answered {method} {path} with something that is not JSON;"
138
171
  " is BENCHWARMERS_URL a Benchwarmers server?") from None
139
172
 
140
173
 
141
- def list_collections(_args: dict) -> list[str]:
174
+ def list_collections(_args: dict, conn: Conn) -> list[str]:
142
175
  rows = []
143
- for m in http("GET", "/v1/models").get("data") or []:
176
+ for m in http(conn, "GET", "/v1/models").get("data") or []:
144
177
  if ":" in m.get("id", ""): # the same collection in its other reply mode
145
178
  continue
146
179
  together = m.get("together") or {}
@@ -153,7 +186,7 @@ def list_collections(_args: dict) -> list[str]:
153
186
  return [json.dumps(rows, indent=1, ensure_ascii=False)]
154
187
 
155
188
 
156
- def ask(args: dict) -> list[str]:
189
+ def ask(args: dict, conn: Conn) -> list[str]:
157
190
  collection, prompt = args.get("collection"), args.get("prompt")
158
191
  if not (isinstance(collection, str) and collection.strip()):
159
192
  raise ToolError("`collection` is required: a name from list_collections, e.g. core")
@@ -168,7 +201,7 @@ def ask(args: dict) -> list[str]:
168
201
  body["bench"] = {"reply": "merged" if args["merged"] else "all"}
169
202
  if args.get("with"):
170
203
  body["with"] = args["with"]
171
- reply = http("POST", "/v1/chat/completions", body)
204
+ reply = http(conn, "POST", "/v1/chat/completions", body)
172
205
  content = reply["choices"][0]["message"]["content"]
173
206
  bench = reply.get("bench") or {}
174
207
  merged = bench.get("reply") == "merged"
@@ -185,19 +218,59 @@ def ask(args: dict) -> list[str]:
185
218
  + json.dumps(data, indent=1, ensure_ascii=False)]
186
219
 
187
220
 
188
- def verdict(args: dict) -> list[str]:
221
+ def pending_verdicts(args: dict, conn: Conn) -> list[str]:
222
+ """For the plugin's Stop hook: the seats of this session's panels with no verdict yet, as the
223
+ hook's feedback for Claude (Claude Code reads it as a command hook's stdout), once per seat;
224
+ nothing when all are judged, already reminded of, or the hook is already continuing a turn.
225
+
226
+ The session's panels are the call ids in its transcript (the hook's transcript_path): Claude
227
+ Code keeps no Mcp-Session-Id, so the transcript is the one record of which panels were whose,
228
+ a subagent's included once its report names them. Read by whichever process runs this: the
229
+ stdio server always can, a server on another machine cannot, and then this says nothing.
230
+ Which of those ids are unjudged seats that answered is the server's call log, so a verdict
231
+ sent any way counts."""
232
+ session, path = args.get("session"), args.get("transcript")
233
+ if str(args.get("active")).lower() == "true" or not (isinstance(session, str) and isinstance(path, str)) \
234
+ or not path.endswith(".jsonl"):
235
+ return [""]
236
+ try:
237
+ with open(path, "rb") as fh:
238
+ fh.seek(max(0, os.fstat(fh.fileno()).st_size - TRANSCRIPT_TAIL))
239
+ calls = list(dict.fromkeys(CALL_ID.findall(fh.read().decode("utf-8", "replace"))))
240
+ except OSError:
241
+ return [""]
242
+ if not calls:
243
+ return [""]
244
+ pending = http(conn, "POST", "/bench/pending", {"calls": calls}).get("pending") or []
245
+ with REMINDED_LOCK:
246
+ if session not in REMINDED and len(REMINDED) >= MAX_SESSIONS:
247
+ REMINDED.pop(next(iter(REMINDED))) # the oldest session
248
+ reminded = REMINDED.setdefault(session, set())
249
+ due = [c for c in pending if c not in reminded]
250
+ reminded.update(due)
251
+ if not due:
252
+ return [""]
253
+ return [json.dumps({"hookSpecificOutput": {"hookEventName": "Stop", "additionalContext": (
254
+ f"Benchwarmers: {len(due)} seat{'s' * (len(due) != 1)} from this session's panels"
255
+ f" {'have' if len(due) != 1 else 'has'} no verdict yet: {', '.join(due)}. Send a verdict for each"
256
+ " seat you judged (the verdict tool, with its true claims in `real` and untrue ones in `false`),"
257
+ " or say in one line why not.")}})]
258
+
259
+
260
+ def verdict(args: dict, conn: Conn) -> list[str]:
189
261
  if not (isinstance(args.get("call"), str) and args["call"].strip()):
190
262
  raise ToolError("`call` is required: the seat's call id from ask")
191
263
  if not isinstance(args.get("useful"), bool):
192
264
  raise ToolError("`useful` must be true or false")
193
265
  body = {"call": args["call"].strip(), "useful": args["useful"], "note": args.get("note") or "",
194
266
  "real": args.get("real") or [], "false": args.get("false") or []}
195
- http("POST", "/bench/verdict", body)
267
+ http(conn, "POST", "/bench/verdict", body)
196
268
  return [f"Recorded {body['call']}: {'useful' if body['useful'] else 'not useful'},"
197
269
  f" {len(body['real'])} real, {len(body['false'])} false."]
198
270
 
199
271
 
200
- HANDLERS = {"list_collections": list_collections, "ask": ask, "verdict": verdict}
272
+ HANDLERS = {"list_collections": list_collections, "ask": ask, "verdict": verdict,
273
+ "pending_verdicts": pending_verdicts}
201
274
  OUT = threading.Lock()
202
275
 
203
276
 
@@ -208,46 +281,70 @@ def send(message: dict) -> None:
208
281
  sys.stdout.buffer.flush()
209
282
 
210
283
 
211
- def call_tool(rid, params: dict) -> None:
284
+ def call_tool(rid, params: dict, conn: Conn) -> dict:
212
285
  name, args = params.get("name"), params.get("arguments") or {}
213
- if name not in HANDLERS:
214
- return send({"jsonrpc": "2.0", "id": rid, "error": {"code": -32602, "message": f"unknown tool {name!r}"}})
286
+ if not isinstance(name, str) or name not in HANDLERS:
287
+ return {"jsonrpc": "2.0", "id": rid, "error": {"code": -32602, "message": f"unknown tool {name!r}"}}
215
288
  try:
216
289
  if not isinstance(args, dict):
217
290
  raise ToolError("arguments must be an object")
218
- texts, failed = HANDLERS[name](args), False
291
+ texts, failed = HANDLERS[name](args, conn), False
219
292
  except ToolError as e:
220
293
  texts, failed = [str(e)], True
221
294
  except Exception as e: # noqa: BLE001 -- a bad reply must not kill the server
222
295
  texts, failed = [f"{type(e).__name__}: {e}"], True
223
- send({"jsonrpc": "2.0", "id": rid,
224
- "result": {"content": [{"type": "text", "text": t} for t in texts], "isError": failed}})
296
+ return {"jsonrpc": "2.0", "id": rid,
297
+ "result": {"content": [{"type": "text", "text": t} for t in texts], "isError": failed}}
225
298
 
226
299
 
227
- def handle(msg) -> None:
300
+ def answer(msg, conn: Conn) -> dict | None:
301
+ """The reply to one JSON-RPC message, or None for a notification. Both
302
+ transports: the stdio loop below and the HTTP route (gateway.py, /mcp).
303
+ Never raises: a request left unanswered would hang its client."""
304
+ try:
305
+ return _answer(msg, conn)
306
+ except Exception as e: # noqa: BLE001
307
+ rid = msg.get("id") if isinstance(msg, dict) else None
308
+ return {"jsonrpc": "2.0", "id": rid, "error": {"code": -32603, "message": f"{type(e).__name__}: {e}"}}
309
+
310
+
311
+ def _answer(msg, conn: Conn) -> dict | None:
228
312
  if not isinstance(msg, dict) or msg.get("jsonrpc") != "2.0" or not isinstance(msg.get("method"), str):
229
313
  rid = msg.get("id") if isinstance(msg, dict) else None
230
- return send({"jsonrpc": "2.0", "id": rid, "error": {"code": -32600, "message": "invalid request"}})
314
+ return {"jsonrpc": "2.0", "id": rid, "error": {"code": -32600, "message": "invalid request"}}
231
315
  if "id" not in msg: # a notification (initialized, cancelled): nothing to answer
232
- return
316
+ return None
233
317
  rid, method, params = msg["id"], msg["method"], msg.get("params") or {}
318
+ if not isinstance(params, dict):
319
+ return {"jsonrpc": "2.0", "id": rid, "error": {"code": -32602, "message": "params must be an object"}}
234
320
  if method == "initialize":
235
321
  asked = params.get("protocolVersion")
236
- send({"jsonrpc": "2.0", "id": rid, "result": {
322
+ return {"jsonrpc": "2.0", "id": rid, "result": {
237
323
  "protocolVersion": asked if asked in PROTOCOLS else PROTOCOLS[0],
238
324
  "capabilities": {"tools": {"listChanged": False}},
239
325
  "serverInfo": {"name": "benchwarmers", "title": "Benchwarmers", "version": VERSION},
240
- "instructions": RULES}})
241
- elif method == "ping":
242
- send({"jsonrpc": "2.0", "id": rid, "result": {}})
243
- elif method == "tools/list":
244
- send({"jsonrpc": "2.0", "id": rid, "result": {"tools": TOOLS}})
245
- elif method == "tools/call":
326
+ "instructions": RULES}}
327
+ if method == "ping":
328
+ return {"jsonrpc": "2.0", "id": rid, "result": {}}
329
+ if method == "tools/list":
330
+ return {"jsonrpc": "2.0", "id": rid, "result": {"tools": TOOLS}}
331
+ if method == "tools/call":
332
+ return call_tool(rid, params, conn)
333
+ return {"jsonrpc": "2.0", "id": rid, "error": {"code": -32601, "message": f"no method {method!r}"}}
334
+
335
+
336
+ def reply_to(msg) -> None:
337
+ if (reply := answer(msg, env_conn())) is not None:
338
+ send(reply)
339
+
340
+
341
+ def handle(msg) -> None:
342
+ if isinstance(msg, dict) and msg.get("method") == "tools/call":
246
343
  # A panel takes minutes: on its own thread, so a ping or a second call is answered meanwhile.
247
344
  # Not a daemon: when stdin closes, a call already sent (a verdict) still finishes.
248
- threading.Thread(target=call_tool, args=(rid, params)).start()
345
+ threading.Thread(target=reply_to, args=(msg,)).start()
249
346
  else:
250
- send({"jsonrpc": "2.0", "id": rid, "error": {"code": -32601, "message": f"no method {method!r}"}})
347
+ reply_to(msg)
251
348
 
252
349
 
253
350
  def main() -> None:
@@ -256,7 +353,7 @@ def main() -> None:
256
353
  continue
257
354
  try:
258
355
  msg = json.loads(raw)
259
- except ValueError:
356
+ except (ValueError, RecursionError): # RecursionError: nesting deeper than json can parse
260
357
  send({"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "parse error"}})
261
358
  continue
262
359
  handle(msg)
package/metrics/client.py CHANGED
@@ -2,8 +2,9 @@
2
2
 
3
3
  python metrics/client.py <alias> "prompt" # one call; prints what ask_once would return
4
4
 
5
- RELEASE-PLAN.md Phases 2a and 2b. A drop-in for `llm -m <alias>`, chosen on
6
- Settings or by BENCH_CLIENT=builtin (see chosen()), used by suite/run.py's
5
+ RELEASE-PLAN.md Phases 2a and 2b. A drop-in for `llm -m <alias>`, and the
6
+ default since v0.3 (`llm` stays a choice on Settings or BENCH_CLIENT=llm; see
7
+ chosen()), used by suite/run.py's
7
8
  ask_once, and so the perf probe, the suite, retries and auto-retire's
8
9
  confirmation call, and by the gateway's seats. It reads the same
9
10
  registration (the yaml entry's model name, base, key name and headers), the
@@ -118,7 +119,6 @@ import json
118
119
  import os
119
120
  import pathlib
120
121
  import random
121
- import shutil
122
122
  import sys
123
123
  import threading
124
124
  import time
@@ -146,10 +146,11 @@ CHOICE = DATA / "config" / "client.json" # the Settings page's choice
146
146
 
147
147
  def chosen() -> tuple[str, str]:
148
148
  """The configured client and what set it: BENCH_CLIENT ("environment"),
149
- else the Settings page's choice ("settings"), else ("default") `llm` where
150
- the llm CLI is installed and the built-in client where it is not, as in
151
- an install from npm or a container. Read on every call, so a change
152
- applies to the next one."""
149
+ else the Settings page's choice ("settings"), else ("default") the
150
+ built-in client, installed `llm` or not: since v0.3 the `llm` CLI is an
151
+ alternative chosen on Settings, no longer the default (its trial week:
152
+ RELEASE-PLAN Phase 2a). Read on every call, so a change applies to the
153
+ next one."""
153
154
  env = (os.environ.get("BENCH_CLIENT") or "").strip().lower()
154
155
  if env:
155
156
  return env, "environment"
@@ -157,7 +158,7 @@ def chosen() -> tuple[str, str]:
157
158
  saved = json.loads(CHOICE.read_text(encoding="utf-8")).get("client")
158
159
  except (OSError, ValueError, AttributeError):
159
160
  saved = None
160
- return (saved, "settings") if saved in CLIENTS else ("llm" if shutil.which("llm") else "builtin", "default")
161
+ return (saved, "settings") if saved in CLIENTS else ("builtin", "default")
161
162
 
162
163
 
163
164
  def backend(alias: str = "") -> str:
package/metrics/guard.py CHANGED
@@ -220,7 +220,14 @@ def startup(address: str) -> None:
220
220
 
221
221
  def gateway_route(path: str) -> bool:
222
222
  """A route the gateway answers, on its own port or the dashboard's."""
223
- return path.startswith(GATEWAY_ROUTES) or path.rstrip("/") in OLLAMA_ROUTES
223
+ return path.startswith(GATEWAY_ROUTES) or path.rstrip("/") in OLLAMA_ROUTES or mcp_route(path)
224
+
225
+
226
+ def mcp_route(path: str) -> bool:
227
+ """MCP over HTTP (gateway.py): /mcp, or /v1/mcp for a BENCHWARMERS_URL
228
+ pasted with its /v1; doubled and trailing slashes from joining a URL
229
+ that ends in one are the same route."""
230
+ return re.sub("/{2,}", "/", path.split("?", 1)[0]).rstrip("/") in ("/mcp", "/v1/mcp")
224
231
 
225
232
 
226
233
  def _sign(expires: int) -> bytes:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wendellresearch-ai/benchwarmers",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Self-hosted panel gateway for LLMs: ask a collection of models at once, keep every answer side by side, rotate explorer models in, and let your verdicts pick the next panel.",
5
5
  "mcpName": "io.github.WendellResearch-AI/benchwarmers",
6
6
  "license": "MIT",
@@ -23,6 +23,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
23
23
 
24
24
  ROOT = pathlib.Path(__file__).resolve().parent.parent
25
25
  SEEN: list[tuple[str, str, dict, str]] = [] # (method, path, body, Authorization)
26
+ JUDGED: set[str] = set() # call ids the stub has a verdict for
26
27
 
27
28
  MODELS = {"object": "list", "data": [
28
29
  {"id": "bench/core", "description": "second opinions", "tags": ["general"], "members": ["m1", "m2"],
@@ -44,7 +45,10 @@ class Stub(BaseHTTPRequestHandler):
44
45
  body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
45
46
  SEEN.append(("POST", self.path, body, self.headers.get("Authorization", "")))
46
47
  if self.path == "/bench/verdict":
48
+ JUDGED.add(body["call"])
47
49
  return self._send(200, {"ok": True, "recorded": {"call": body["call"]}})
50
+ if self.path == "/bench/pending":
51
+ return self._send(200, {"pending": [c for c in body["calls"] if c not in JUDGED]})
48
52
  if body["model"] == "bench/nope":
49
53
  return self._send(404, {"error": {"message": "no collection nope; choose one of bench/core"}})
50
54
  time.sleep(1) # a panel takes a while
@@ -112,7 +116,8 @@ def main() -> int:
112
116
  assert c.request("ping")["result"] == {}
113
117
 
114
118
  tools = {t["name"]: t for t in c.request("tools/list")["result"]["tools"]}
115
- assert set(tools) == {"list_collections", "ask", "verdict"}, tools
119
+ assert set(tools) == {"list_collections", "ask", "verdict", "pending_verdicts"}, tools
120
+ assert c.tool("pending_verdicts", active="false")["content"][0]["text"] == "" # no panel yet: nothing
116
121
  assert tools["ask"]["inputSchema"]["required"] == ["collection", "prompt"]
117
122
 
118
123
  r = c.tool("list_collections")
@@ -153,10 +158,40 @@ def main() -> int:
153
158
  "real": ["a true claim"], "false": ["x"]}), SEEN[-1]
154
159
  assert c.tool("verdict", call="p1.member1")["isError"] # useful is required
155
160
 
161
+ # The Stop hook's tool: the call ids in the session's transcript (each once), the server says which
162
+ # are unjudged, and those come back as feedback for Claude, once; nothing while the hook is already
163
+ # continuing a turn, nor for a transcript it cannot read.
164
+ import tempfile
165
+ a, b = "p20261025-120000-abcd.member1", "p20261025-120000-abcd.explorer2"
166
+ with tempfile.TemporaryDirectory() as tmp:
167
+ transcript = pathlib.Path(tmp) / "session.jsonl"
168
+ transcript.write_text(f'{{"tool_result": "`{a}` ok"}}\n{{"tool_result": "`{b}` ok, `{a}` again"}}\n',
169
+ encoding="utf-8")
170
+ hook = {"session": "s", "transcript": str(transcript)}
171
+ assert not c.tool("verdict", call=a, useful=True)["isError"]
172
+ assert c.tool("pending_verdicts", active="true", **hook)["content"][0]["text"] == ""
173
+ out = json.loads(c.tool("pending_verdicts", active="false", **hook)["content"][0]["text"])
174
+ assert SEEN[-1][1:3] == ("/bench/pending", {"calls": [a, b]}), SEEN[-1]
175
+ ctx = out["hookSpecificOutput"]
176
+ assert ctx["hookEventName"] == "Stop" and "1 seat " in ctx["additionalContext"], ctx
177
+ assert b in ctx["additionalContext"] and a not in ctx["additionalContext"], ctx
178
+ assert c.tool("pending_verdicts", active="false", **hook)["content"][0]["text"] == "" # reminded
179
+ assert c.tool("pending_verdicts", session="t", transcript=str(transcript) + ".missing.jsonl",
180
+ active="false")["content"][0]["text"] == ""
181
+ assert c.tool("pending_verdicts", session="t", transcript=str(pathlib.Path(tmp) / "x.txt"),
182
+ active="false")["content"][0]["text"] == "" # not a transcript
183
+
156
184
  assert c.request("tools/call", {"name": "nope"})["error"]["code"] == -32602
185
+ assert c.request("tools/call", {"name": ["x"]})["error"]["code"] == -32602 # unhashable: answered
157
186
  assert c.request("resources/list")["error"]["code"] == -32601
158
187
  c.send("{not json")
159
188
  assert c.read()["error"]["code"] == -32700
189
+ c.send("[" * 100_000) # deeper than json parses
190
+ assert c.read()["error"]["code"] == -32700
191
+ for method in ("initialize", "tools/call"): # params not an object: answered, never raised
192
+ c.send({"jsonrpc": "2.0", "id": method, "method": method, "params": [1]})
193
+ assert c.read() == {"jsonrpc": "2.0", "id": method,
194
+ "error": {"code": -32602, "message": "params must be an object"}}
160
195
  c.close()
161
196
 
162
197
  # An unknown protocol version gets the newest; no server running is a clear tool error.
@@ -170,9 +205,179 @@ def main() -> int:
170
205
  assert r["isError"] and "not running" in r["content"][0]["text"], r
171
206
  c.close()
172
207
  stub.shutdown()
173
- print("mcp server ok: initialize, ping, tools/list, each tool, refusals, a ping mid-panel, not running")
208
+ over_http()
209
+ print("mcp server ok: initialize, ping, tools/list, each tool, refusals, a ping mid-panel, not running;"
210
+ " /mcp on both ports, its paths, the key and its scope through the loopback, the guard;"
211
+ " pending_verdicts and /bench/pending for the Stop hook")
174
212
  return 0
175
213
 
176
214
 
215
+ def over_http() -> None:
216
+ """POST /mcp on the real handlers, both ports: the tools reach the server's own routes with the caller's key."""
217
+ import tempfile
218
+ import urllib.error
219
+ import urllib.request
220
+ sys.path.insert(0, str(ROOT))
221
+ import serve
222
+ import gateway
223
+ import guard
224
+
225
+ def post(port, path, msg, **headers):
226
+ headers = {"Host": f"127.0.0.1:{port}", "Content-Type": "application/json", **headers}
227
+ data = msg.encode() if isinstance(msg, str) else json.dumps(msg).encode()
228
+ req = urllib.request.Request(f"http://127.0.0.1:{port}{path}", data, headers,
229
+ method="POST" if msg is not None else "GET")
230
+ try:
231
+ with urllib.request.urlopen(req, timeout=30) as r:
232
+ raw = r.read()
233
+ return r.status, json.loads(raw) if raw else None
234
+ except urllib.error.HTTPError as e:
235
+ return e.code, json.loads(e.read() or b"null")
236
+
237
+ def tool(port, name, path="/mcp", **headers_and_args):
238
+ headers = {k: headers_and_args.pop(k) for k in ("Authorization", "Origin") if k in headers_and_args}
239
+ status, reply = post(port, path, {"jsonrpc": "2.0", "id": 7, "method": "tools/call",
240
+ "params": {"name": name, "arguments": headers_and_args}}, **headers)
241
+ assert status == 200 and reply["id"] == 7, (status, reply)
242
+ return reply["result"]
243
+
244
+ servers = [ThreadingHTTPServer(("127.0.0.1", 0), h) for h in (serve.Handler, gateway.Handler)]
245
+ for s in servers:
246
+ threading.Thread(target=s.serve_forever, daemon=True).start()
247
+ saved = (guard.KEYS, gateway.bench_collections.FILE, gateway.run_collection, sys.stderr,
248
+ os.environ.pop("BENCHWARMERS_ADMIN_TOKEN", None), os.environ.pop("BENCHWARMERS_REQUIRE_KEYS", None),
249
+ gateway.offload.LOG)
250
+ ran = []
251
+ with tempfile.TemporaryDirectory() as tmp:
252
+ try:
253
+ t = pathlib.Path(tmp)
254
+ guard.KEYS, gateway.bench_collections.FILE = t / "gateway-keys.json", t / "collections.json"
255
+ gateway.bench_collections.FILE.write_text(json.dumps({"core": {"members": [], "description": "x"},
256
+ "vision": {"members": [], "description": "y"}}),
257
+ encoding="utf-8")
258
+ gateway.offload.LOG = t / "offloads.jsonl"
259
+ gateway.run_collection = lambda name, messages, nominated, reply, who: (
260
+ ran.append((name, who)) or f"answer from {name}",
261
+ {"seats": [{"call": f"{name}.member1", "seat": "member", "alias": "m1", "status": "ok"},
262
+ {"call": f"{name}.explorer", "seat": "explorer", "alias": "x1", "status": "error"}]})
263
+ sys.stderr = open(os.devnull, "w") # the access log
264
+ for port in (s.server_port for s in servers):
265
+ init = {"jsonrpc": "2.0", "id": 1, "method": "initialize",
266
+ "params": {"protocolVersion": "2025-06-18", "capabilities": {}}}
267
+ # A BENCHWARMERS_URL with /v1 or a trailing slash, joined to /mcp, is the same route.
268
+ for path in ("/mcp", "/mcp/", "//mcp", "/v1/mcp", "/v1//mcp"):
269
+ status, reply = post(port, path, init)
270
+ assert status == 200 and reply["result"]["serverInfo"]["name"] == "benchwarmers", (path, reply)
271
+ assert not guard.mcp_route("/mcpx") and not guard.mcp_route("/v1/mcp/x")
272
+ assert post(port, "/mcp", {"jsonrpc": "2.0", "method": "notifications/initialized"}) == (202, None)
273
+ assert post(port, "/mcp", None)[0] == 405 # no server stream
274
+ assert post(port, "/mcp", "{not json") == (400, {"jsonrpc": "2.0", "id": None,
275
+ "error": {"code": -32700, "message": "parse error"}})
276
+ status, reply = post(port, "/mcp", {"jsonrpc": "2.0", "id": 2, "method": "tools/list"})
277
+ assert {x["name"] for x in reply["result"]["tools"]} == {"list_collections", "ask", "verdict",
278
+ "pending_verdicts"}
279
+ r = tool(port, "list_collections", path="//mcp")
280
+ assert not r["isError"] and [x["name"] for x in json.loads(r["content"][0]["text"])] == \
281
+ ["bench/core", "bench/vision"], r
282
+ r = tool(port, "ask", collection="core", prompt="hi")
283
+ assert not r["isError"] and "answer from core" in r["content"][0]["text"], r
284
+ # A client that takes SSE (every streamable-HTTP client must) gets a tool call as a stream,
285
+ # keep-alives while it runs and then the reply, so its idle timeouts never fire mid-panel.
286
+ saved_keepalive, gateway.KEEPALIVE = gateway.KEEPALIVE, 0.2
287
+ slow, gateway.run_collection = gateway.run_collection, lambda *a, **k: (time.sleep(0.7), slow(*a, **k))[1]
288
+ req = urllib.request.Request(
289
+ f"http://127.0.0.1:{port}/mcp", json.dumps({"jsonrpc": "2.0", "id": 9, "method": "tools/call",
290
+ "params": {"name": "ask", "arguments": {
291
+ "collection": "core", "prompt": "hi"}}}).encode(),
292
+ {"Host": f"127.0.0.1:{port}", "Content-Type": "application/json",
293
+ "Accept": "application/json, text/event-stream"})
294
+ with urllib.request.urlopen(req, timeout=30) as resp:
295
+ assert resp.headers["Content-Type"] == "text/event-stream", resp.headers
296
+ events = resp.read().decode()
297
+ assert events.startswith(": keep-alive\n\n") and events.endswith("\n\n"), events
298
+ reply = json.loads(events.rsplit("data: ", 1)[1])
299
+ assert reply["id"] == 9 and "answer from core" in reply["result"]["content"][0]["text"], reply
300
+ # With a progressToken, progress notifications instead: what resets a client's idle timeout.
301
+ req = urllib.request.Request(
302
+ f"http://127.0.0.1:{port}/mcp", json.dumps({"jsonrpc": "2.0", "id": 11, "method": "tools/call",
303
+ "params": {"name": "ask", "_meta": {"progressToken": "t1"},
304
+ "arguments": {"collection": "core",
305
+ "prompt": "hi"}}}).encode(),
306
+ {"Host": f"127.0.0.1:{port}", "Content-Type": "application/json",
307
+ "Accept": "application/json, text/event-stream"})
308
+ with urllib.request.urlopen(req, timeout=30) as resp:
309
+ messages = [json.loads(line[6:]) for line in resp.read().decode().splitlines()
310
+ if line.startswith("data: ")]
311
+ gateway.KEEPALIVE, gateway.run_collection = saved_keepalive, slow
312
+ assert ": keep-alive" not in json.dumps(messages) and len(messages) >= 2, messages
313
+ assert all(m["method"] == "notifications/progress" and m["params"]["progressToken"] == "t1"
314
+ for m in messages[:-1]), messages
315
+ assert messages[-1]["id"] == 11 and not messages[-1]["result"]["isError"], messages[-1]
316
+ # A tool call that cannot be answered still ends its stream (it once kept it open forever).
317
+ req = urllib.request.Request(
318
+ f"http://127.0.0.1:{port}/mcp", json.dumps({"jsonrpc": "2.0", "id": 10, "method": "tools/call",
319
+ "params": [1]}).encode(),
320
+ {"Host": f"127.0.0.1:{port}", "Content-Type": "application/json",
321
+ "Accept": "application/json, text/event-stream"})
322
+ with urllib.request.urlopen(req, timeout=5) as resp:
323
+ reply = json.loads(resp.read().decode().rsplit("data: ", 1)[1])
324
+ assert reply["id"] == 10 and reply["error"]["code"] == -32602, reply
325
+ assert post(port, "/mcp", "[" * 100_000)[1]["error"]["code"] == -32700
326
+ # The guard on /mcp itself: Host and Origin, as on /v1.
327
+ assert post(port, "/mcp", init, Host="evil.example")[0] == 403
328
+ assert post(port, "/mcp", init, Origin="http://evil.example")[0] == 403
329
+ assert ran == [("core", "local")] * 6, ran
330
+
331
+ # The Stop hook over HTTP: the call ids in the transcript, of which the call log says which are
332
+ # seats that answered (ok or empty, not errored) and have no verdict; reminded of once; a
333
+ # verdict sent any way clears one; an id the log does not know is not a seat.
334
+ g = servers[1].server_port
335
+ ok, empty, failed, judged, unknown = (f"p20261025-130000-beef.{s}" for s in
336
+ ("member1", "member2", "explorer", "explorer2", "member9"))
337
+ for call, note in ((ok, "member; ok in 1.0s"), (empty, "member; empty in 2.0s"),
338
+ (failed, "explorer; error in 0.4s: Error: 429"), (judged, "explorer 2; ok in 1.0s")):
339
+ gateway.offload.add("m", "t", None, note, call)
340
+ gateway.offload.add("m", "t", True, "checked", judged)
341
+ status, reply = post(g, "/bench/pending", {"calls": [ok, empty, failed, judged, unknown]})
342
+ assert (status, reply) == (200, {"pending": [ok, empty]}), reply
343
+ assert post(g, "/bench/pending", {"calls": "x"})[0] == 400
344
+ transcript = t / "claude-1.jsonl"
345
+ transcript.write_text(" ".join((ok, empty, failed, judged, unknown, ok)), encoding="utf-8")
346
+
347
+ def pending(session="claude-1"):
348
+ return tool(g, "pending_verdicts", session=session, transcript=str(transcript),
349
+ active="false")["content"][0]["text"]
350
+ ctx = json.loads(pending())["hookSpecificOutput"]["additionalContext"]
351
+ assert "2 seats " in ctx and ok in ctx and empty in ctx and failed not in ctx and judged not in ctx, ctx
352
+ assert pending() == "" # once per session
353
+ assert ok in pending("claude-2") # another session: its own reminder
354
+ assert not tool(g, "verdict", call=ok, useful=True)["isError"]
355
+ assert post(g, "/bench/pending", {"calls": [ok, empty]})[1] == {"pending": [empty]}
356
+
357
+ # Keys required: /mcp asks for one, and the loopback carries it, scope and all.
358
+ os.environ["BENCHWARMERS_REQUIRE_KEYS"] = "1"
359
+ vision = {"Authorization": "Bearer " + guard.make_key("vision-only", ["vision"])}
360
+ ran.clear()
361
+ for port in (s.server_port for s in servers):
362
+ assert post(port, "/mcp", init)[0] == 401
363
+ assert post(port, "/mcp", init, Authorization="Bearer ")[0] == 401 # the plugin with no key set
364
+ r = tool(port, "list_collections", **vision)
365
+ assert [x["name"] for x in json.loads(r["content"][0]["text"])] == ["bench/vision"], r
366
+ r = tool(port, "ask", collection="core", prompt="hi", **vision)
367
+ assert r["isError"] and "may call bench/vision" in r["content"][0]["text"], r
368
+ assert not tool(port, "ask", collection="vision", prompt="hi", **vision)["isError"]
369
+ assert ran == [("vision", "vision-only")] * 2, ran
370
+ finally:
371
+ sys.stderr.close()
372
+ guard.KEYS, gateway.bench_collections.FILE, gateway.run_collection, sys.stderr = saved[:4]
373
+ gateway.offload.LOG = saved[6]
374
+ for name, value in (("BENCHWARMERS_ADMIN_TOKEN", saved[4]), ("BENCHWARMERS_REQUIRE_KEYS", saved[5])):
375
+ os.environ.pop(name, None)
376
+ if value is not None:
377
+ os.environ[name] = value
378
+ for s in servers:
379
+ s.shutdown()
380
+
381
+
177
382
  if __name__ == "__main__":
178
383
  sys.exit(main())
@@ -291,7 +291,7 @@ def main() -> int:
291
291
  model_client.CHOICE = cfg / "client.json"
292
292
  env, real_which = os.environ.pop("BENCH_CLIENT", None), shutil.which
293
293
  try:
294
- shutil.which = lambda name: "/usr/bin/" + name # llm installed: the default is llm
294
+ shutil.which = lambda name: "/usr/bin/" + name # llm installed: still built-in
295
295
  first = model_client.chosen()
296
296
  shutil.which = lambda name: None # not installed (npm, a container)
297
297
  bare = model_client.chosen()
@@ -310,7 +310,7 @@ def main() -> int:
310
310
  os.environ.pop("BENCH_CLIENT", None)
311
311
  if env is not None:
312
312
  os.environ["BENCH_CLIENT"] = env
313
- ok = (first == ("llm", "default") and bare == ("builtin", "default") and made["ok"] and picked == (("builtin", "settings"), "builtin")
313
+ ok = (first == ("builtin", "default") and bare == ("builtin", "default") and made["ok"] and picked == (("builtin", "settings"), "builtin")
314
314
  and pinned == (("llm", "environment"), False) and not no_llm and not unknown)
315
315
  print(("ok " if ok else "FAIL ") + "model client choice" + ("" if ok else f" -> {first} {made} {picked} {pinned}"))
316
316
  failures += 0 if ok else 1