@wendellresearch-ai/benchwarmers 0.3.1 → 0.3.3

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/GATEWAY.md CHANGED
@@ -167,6 +167,14 @@ A verdict is `POST /bench/verdict` with `{"call", "useful": true|false, "note",
167
167
  dialog in the dashboard's Collections tab, under Gateway (click a seat). Sending the same verdict
168
168
  twice writes nothing.
169
169
 
170
+ A client that weighs its seats by how they have done reads the verdicts back:
171
+ `GET /bench/record?collection=bench/vision&task=thicken slab faces` answers
172
+ `{"collection", "task", "aliases": {"<alias>": {"useful", "notUseful",
173
+ "judged", "lastAt"}}}`, counting each model's judged seats on that collection.
174
+ `task` (optional) keeps the verdicts whose note holds it. Counts only: never a
175
+ prompt, an answer or a note. thicken breaks ties between its vision seats with
176
+ it.
177
+
170
178
  ## Streaming
171
179
 
172
180
  With `"stream": true` (or the format's own way of asking, below):
@@ -511,7 +519,7 @@ key limited to its collection and a rate, or keep the key on the app's server.
511
519
  | Catalog | `metrics/catalog.py`: context window, output limit, rate limits and capabilities per model, refreshed daily (scheduled task WRBench-Catalog); a free model whose price goes above zero raises a "went paid" alert. |
512
520
  | Discover tab | `metrics/discover.py`: models.dev and FreeLLMAPI merged into one list of models worth adding, free first, marked by whether you can already reach the provider and whether the model is on the bench (on another provider counts as "also on bench"). |
513
521
  | Merged replies | Opt-in: one merger answer as `content`, every seat still in `bench` (The reply). |
514
- | MCP server | `mcp_server.py` (`benchwarmers mcp`): the same three routes as MCP tools for agents, `list_collections`, `ask` and `verdict`, over HTTP to a running server (`BENCHWARMERS_URL`, `BENCHWARMERS_KEY`), so it is a caller like any other. The Claude Code plugin in `plugin/` wraps it with a command and a skill. |
522
+ | MCP server | `mcp_server.py`: the same three routes as MCP tools for agents, `list_collections`, `ask` and `verdict`. Two transports for one `answer()`: `POST /mcp` (or `/v1/mcp`) on either port, streamable HTTP with a JSON reply per request, its tools calling the server's own routes over loopback with the caller's credential and Host; and stdio (`benchwarmers mcp`), a caller like any other over HTTP (`BENCHWARMERS_URL`, `BENCHWARMERS_KEY`). The Claude Code plugin in `plugin/` connects to `/mcp` and adds a command, a skill and a `Stop` hook (`plugin/hooks/hooks.json`, an `mcp_tool` hook) that calls `pending_verdicts` with the session's `transcript_path`: the call ids in the transcript (Claude Code keeps no `Mcp-Session-Id`, so the transcript is the record of whose panels they were), of which the call log says which are answered seats with no verdict (`POST /bench/pending`), returned once as `additionalContext`. A server on another machine cannot read the transcript, so there the hook says nothing. A tool call answers as SSE with progress notifications, which reset Claude Code's 300-second idle limit. |
515
523
 
516
524
  ## Rules
517
525
 
package/README.md CHANGED
@@ -6,10 +6,11 @@
6
6
  </h1>
7
7
 
8
8
  <p align="center">
9
- <a href="https://github.com/WendellResearch-AI/benchwarmers/releases"><img src="https://img.shields.io/github/v/release/WendellResearch-AI/benchwarmers?color=990000" alt="Latest release"></a>
9
+ <a href="https://github.com/WendellResearch-AI/benchwarmers/releases/latest"><img src="https://img.shields.io/github/v/release/WendellResearch-AI/benchwarmers?logo=github&color=181717" alt="Latest release"></a>
10
10
  <a href="https://www.npmjs.com/package/@wendellresearch-ai/benchwarmers"><img src="https://img.shields.io/npm/v/@wendellresearch-ai/benchwarmers?color=cb3837&logo=npm" alt="npm"></a>
11
11
  <a href="https://pypi.org/project/benchwarmers/"><img src="https://img.shields.io/pypi/v/benchwarmers?color=3775a9&logo=pypi&logoColor=white" alt="PyPI"></a>
12
- <a href="https://github.com/WendellResearch-AI/benchwarmers/actions/workflows/check.yml"><img src="https://github.com/WendellResearch-AI/benchwarmers/actions/workflows/check.yml/badge.svg" alt="Checks"></a>
12
+ <a href="https://github.com/WendellResearch-AI/benchwarmers/actions/workflows/check.yml"><img src="https://img.shields.io/github/actions/workflow/status/WendellResearch-AI/benchwarmers/check.yml?branch=main&logo=github&label=CI%20tests" alt="CI tests"></a>
13
+ <a href="https://registry.modelcontextprotocol.io/?q=benchwarmers"><img src="https://img.shields.io/badge/MCP_Registry-111111?logo=modelcontextprotocol&logoColor=white" alt="In the official MCP Registry"></a>
13
14
  <a href="#docker-and-hosting"><img src="https://img.shields.io/badge/Docker-compose-2496ED?logo=docker&logoColor=white" alt="Runs in Docker"></a>
14
15
  <br>
15
16
  <a href="GATEWAY.md#other-api-formats"><img src="https://img.shields.io/badge/API-OpenAI_%7C_Responses-111111" alt="OpenAI chat completions and Responses API"></a>
@@ -17,7 +18,6 @@
17
18
  <a href="GATEWAY.md#other-api-formats"><img src="https://img.shields.io/badge/API-Gemini-111111" alt="Gemini API"></a>
18
19
  <a href="GATEWAY.md#other-api-formats"><img src="https://img.shields.io/badge/API-Ollama-111111" alt="Ollama API"></a>
19
20
  <a href="GATEWAY.md#route-mode-agent-clients"><img src="https://img.shields.io/badge/agents-tool_calling-111111" alt="Agent clients with tool calling, through route mode"></a>
20
- <a href="https://registry.modelcontextprotocol.io/v0/servers?search=io.github.WendellResearch-AI/benchwarmers"><img src="https://img.shields.io/badge/MCP-Registry-111111" alt="MCP server in the official MCP Registry"></a>
21
21
  <br>
22
22
  <a href="#try-it"><img src="https://img.shields.io/badge/Python-3.11_%7C_3.12_%7C_3.13-3776AB?logo=python&logoColor=white" alt="Python 3.11 to 3.13"></a>
23
23
  <a href="#try-it"><img src="https://img.shields.io/badge/core-stdlib_only-2ea44f" alt="The core needs only the Python standard library"></a>
@@ -279,28 +279,47 @@ no server at `BENCHWARMERS_URL`.
279
279
 
280
280
  ## From Claude Code and other agents
281
281
 
282
- `benchwarmers mcp` is an MCP server on stdio with three tools:
283
- `list_collections`, `ask` (a collection, a prompt, optionally `merged`,
284
- `with` and `system`) and `verdict`. It is a client of a Benchwarmers that is
285
- already running, over the routes above, so start the dashboard (or the
286
- service) first. The tool descriptions carry the rules: ask a collection, not
287
- a favourite model; verify before acting; name every seat; send a verdict per
288
- seat you judged.
282
+ A running Benchwarmers is an MCP server at `/mcp` on its own port
283
+ (`http://127.0.0.1:8122/mcp`; `/v1/mcp` works too), over streamable HTTP,
284
+ with three tools: `list_collections`, `ask` (a collection, a prompt,
285
+ optionally `merged`, `with` and `system`) and `verdict`. Nothing to start per
286
+ client: every agent session shares the one server, behind the same Host,
287
+ Origin and gateway-key checks as `/v1`. The tool descriptions carry the
288
+ rules: ask a collection, not a favourite model; verify before acting; name
289
+ every seat; send a verdict per seat you judged.
289
290
 
290
- **Claude Code plugin.** The plugin adds the MCP server, a `/benchwarmers:ask
291
- <collection> <what to ask>` command and a `benchwarmers` skill saying when a
292
- panel helps (adversarial review, exhaustive enumeration over real files) and
293
- how to judge it:
291
+ **Claude Code plugin.** The plugin connects to that `/mcp` and adds a
292
+ `/benchwarmers:ask <collection> <what to ask>` command and a `benchwarmers`
293
+ skill saying when a panel helps (adversarial review, exhaustive enumeration
294
+ over real files) and how to judge it:
294
295
 
295
296
  /plugin marketplace add WendellResearch-AI/benchwarmers
296
297
  /plugin install benchwarmers@benchwarmers
297
298
 
298
- It needs Node 18 or later and Python 3.11 or later on PATH.
299
+ It finds the server through `BENCHWARMERS_URL` (default
300
+ `http://127.0.0.1:8122`; set it if you changed `BENCH_PORT`) and sends
301
+ `BENCHWARMERS_KEY` where keys are required. It also adds a `Stop` hook: at
302
+ the end of a turn it asks the server (the `pending_verdicts` tool, so nothing
303
+ extra runs on your machine) which seats of that session's panels have no
304
+ verdict yet, and reminds Claude of them once, as hook feedback. The server
305
+ finds the session's panels in its transcript, so the reminder needs a server
306
+ on the same machine; with one elsewhere the hook stays silent. A verdict sent
307
+ any way counts, the dashboard's included. With the plugin enabled and no
308
+ Benchwarmers running, the hook shows a hook-error notice at the end of each
309
+ turn, as `/mcp` shows the server as failed; start the server, or disable the
310
+ plugin for that session.
311
+
312
+ **Any other MCP client** that speaks HTTP:
299
313
 
300
- **Any other MCP client** (or Claude Code without the plugin): run
301
- `benchwarmers mcp` once installed, `uvx benchwarmers mcp` or
314
+ ```json
315
+ {"mcpServers": {"benchwarmers": {"type": "http", "url": "http://127.0.0.1:8122/mcp"}}}
316
+ ```
317
+
318
+ with `"headers": {"Authorization": "Bearer <gateway key>"}` where keys are
319
+ required. **A client that only speaks stdio**: run `benchwarmers mcp` once
320
+ installed, `uvx benchwarmers mcp` or
302
321
  `npx -y @wendellresearch-ai/benchwarmers mcp` without installing, or `python mcp_server.py` from a
303
- checkout. For example:
322
+ checkout, a small process per client that calls the same server. For example:
304
323
 
305
324
  ```json
306
325
  {"mcpServers": {"benchwarmers": {"command": "npx", "args": ["-y", "@wendellresearch-ai/benchwarmers", "mcp"],
@@ -316,6 +335,24 @@ On native Windows, a client that cannot start `npx` itself needs
316
335
  | `BENCHWARMERS_KEY` | a gateway key from Settings, sent as `Authorization: Bearer`; needed for a server on another machine, or where keys are required |
317
336
  | `BENCHWARMERS_TIMEOUT` | seconds to wait for a panel, default 900 |
318
337
 
338
+ **Behind an MCP aggregator** (1mcp, MetaMCP, mcp-proxy, Docker's MCP Gateway
339
+ and the like): add `http://127.0.0.1:8122/mcp` as an HTTP (streamable HTTP)
340
+ upstream, with the `Authorization` header where keys are required; an
341
+ aggregator that only runs stdio servers takes `benchwarmers mcp` as above.
342
+ Give it a request timeout of a few minutes or more, as a panel takes that
343
+ long; 1mcp's default of 60 seconds ends most of them. With 1mcp, for example:
344
+
345
+ 1mcp mcp add benchwarmers --type http --url http://127.0.0.1:8122/mcp --timeout 900000
346
+
347
+ The aggregator prefixes the tool names (1mcp's are
348
+ `benchwarmers_1mcp_ask` and so on). If you also use the Claude Code plugin,
349
+ disable the plugin's own `benchwarmers` server in `/mcp` so the tools are not
350
+ listed twice; the skill and `/benchwarmers:ask` keep working, though the
351
+ command then asks before each tool call, since its pre-approved tools carry
352
+ the plugin's names. The plugin's verdict hook calls the plugin's own server,
353
+ so it does not work through an aggregator: with that server disabled it only
354
+ shows a hook-error notice at the end of each turn.
355
+
319
356
  **An agent running on a collection.** The MCP tools ask a panel for a
320
357
  second opinion; an agent can also *be driven by* a collection, in **route
321
358
  mode** (`bench/<collection>:route`): one member, picked by its record, gets
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.3.1
1
+ 0.3.3
package/gateway.py CHANGED
@@ -150,6 +150,7 @@ import client # noqa: E402
150
150
  import decide # noqa: E402
151
151
  import formats # noqa: E402
152
152
  import guard # noqa: E402
153
+ import mcp_server # noqa: E402 -- its tools, answered at /mcp too
153
154
  import model_roles # noqa: E402
154
155
  import offload # noqa: E402
155
156
  import route # noqa: E402
@@ -440,12 +441,33 @@ def too_small(needed: int, aliases) -> dict[str, int]:
440
441
 
441
442
  def record_seat(task: str, call: str, label: str, alias: str, r: dict) -> dict:
442
443
  """Log one answer as an unjudged offload, the moment it is in, so explorer
443
- rotation counts it even if nobody judges; returns the seat as logged."""
444
+ rotation counts it even if nobody judges; returns the seat as logged. The
445
+ note's "<seat>; <status> in" is what /bench/pending reads the status from."""
444
446
  offload.add(alias, task, None, f"{label}; {r['status']} in {r['seconds']}s"
445
447
  + (f": {r['error'][:120]}" if r["error"] else ""), call)
446
448
  return {"call": call, "seat": label, "alias": alias, **r}
447
449
 
448
450
 
451
+ def record(collection: str, task: str = "") -> dict[str, dict]:
452
+ """Each model's verdicts on one collection's seats, for a client that weighs
453
+ its seats by how they have done (thicken breaks ties with it): useful, not
454
+ useful and judged, counted, and the last one's date. `task` keeps only the
455
+ verdicts whose note holds it ("thicken slab faces"). Counts only: never a
456
+ prompt, an answer or a note."""
457
+ out: dict[str, dict] = {}
458
+ for e in offload.entries():
459
+ # The collection ends each seat's task as run_collection writes it: "(bench/<name>, <shape>)".
460
+ if e.get("useful") is None or f"({collection}," not in e.get("task", ""):
461
+ continue
462
+ if task and task not in (e.get("note") or ""):
463
+ continue
464
+ r = out.setdefault(e["model"], {"useful": 0, "notUseful": 0, "judged": 0, "lastAt": ""})
465
+ r["useful" if e["useful"] else "notUseful"] += 1
466
+ r["judged"] += 1
467
+ r["lastAt"] = max(r["lastAt"], e.get("date", ""))
468
+ return out
469
+
470
+
449
471
  def merger_candidates(collection: dict, members: list[str], shape: str, large: bool):
450
472
  """Who may merge, best first. The collection's `merger` alone when set;
451
473
  else the members with verdicts on this shape, most useful first, then the
@@ -1030,6 +1052,12 @@ class Handler(BaseHTTPRequestHandler):
1030
1052
  # orchestrator can choose one by task. Clients ignore extra keys.
1031
1053
  return self._json(200, {"object": "list", "data": [
1032
1054
  {"id": n, "object": "model", "owned_by": "benchwarmers", **d} for n, d in listed(self.who)]})
1055
+ if path == "/bench/record":
1056
+ query = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query)
1057
+ collection, task = (query.get(k, [""])[0] for k in ("collection", "task"))
1058
+ if not collection:
1059
+ return self._error(400, "GET /bench/record?collection=bench/<name>[&task=<words in the verdicts' notes>]")
1060
+ return self._json(200, {"collection": collection, "task": task, "aliases": record(collection, task)})
1033
1061
  if path == "/api/tags":
1034
1062
  return self._json(200, formats.ollama_tags(listed(self.who)))
1035
1063
  if path == "/api/version":
@@ -1041,7 +1069,10 @@ class Handler(BaseHTTPRequestHandler):
1041
1069
  row = dict(listed(self.who)).get(name)
1042
1070
  return self._json(200, formats.gemini_model(name, row)) if row else \
1043
1071
  self._error(404, f"{name!r} is not a collection here; GET /v1beta/models lists them", fmt=fmt)
1044
- self._error(404, "GET /v1/models, /api/tags, /api/version or /v1beta/models", fmt=fmt)
1072
+ if guard.mcp_route(path): # no stream from the server: every reply is a POST's
1073
+ return self._json(405, {"error": {"message": "POST /mcp, one JSON-RPC message per request"}},
1074
+ {"Allow": "POST"})
1075
+ self._error(404, "GET /v1/models, /bench/record, /api/tags, /api/version or /v1beta/models", fmt=fmt)
1045
1076
 
1046
1077
  def do_POST(self): # noqa: N802
1047
1078
  # Without this any web page could spend model calls, seat ad hoc
@@ -1053,6 +1084,8 @@ class Handler(BaseHTTPRequestHandler):
1053
1084
  self.who = guard.gateway_caller(self.headers, self._query_key(fmt))
1054
1085
  except guard.Refused as r:
1055
1086
  return self._error(r.status, str(r) + (TOO_BIG if r.status == 413 else ""), r.headers, fmt)
1087
+ if guard.mcp_route(path):
1088
+ return self._mcp(length)
1056
1089
  try:
1057
1090
  body = json.loads(self.rfile.read(length) or b"{}")
1058
1091
  except ValueError:
@@ -1071,6 +1104,22 @@ class Handler(BaseHTTPRequestHandler):
1071
1104
  except KeyError as exc:
1072
1105
  return self._error(404, exc.args[0])
1073
1106
  return self._json(200, {"ok": True, "recorded": e})
1107
+ if path == "/bench/pending":
1108
+ # Which of these call ids are seats that answered (ok or empty, not errored) and have
1109
+ # no verdict yet, however one is sent (mcp_server.py's pending_verdicts, the plugin's
1110
+ # Stop hook). An id the log does not know is not a seat. A seat's first entry is
1111
+ # record_seat's, its note "<seat>; <status> in <s>s".
1112
+ calls = body.get("calls")
1113
+ if not (isinstance(calls, list) and all(isinstance(c, str) for c in calls)):
1114
+ return self._error(400, '"calls" must be a list of call ids')
1115
+ first, judged = {}, set()
1116
+ for e in offload.entries():
1117
+ first.setdefault(e.get("call"), e)
1118
+ if e.get("useful") is not None:
1119
+ judged.add(e.get("call"))
1120
+ return self._json(200, {"pending": [
1121
+ c for c in calls if c in first and c not in judged
1122
+ and "; error in " not in str(first[c].get("note", "")).split(":", 1)[0]]})
1074
1123
  # Each format's request becomes one Ask (metrics/formats.py); a part
1075
1124
  # that would be lost on the way, tools above all, is refused with why.
1076
1125
  gemini = formats.gemini_route(path)
@@ -1099,6 +1148,66 @@ class Handler(BaseHTTPRequestHandler):
1099
1148
  alt = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query).get("alt", [""])[0]
1100
1149
  self._panel(fmt, ask, sse=alt == "sse")
1101
1150
 
1151
+ def _mcp(self, length: int) -> None:
1152
+ """MCP over streamable HTTP, a reply to each POST (JSON, or SSE for a
1153
+ tool call from a client that takes it): mcp_server.py's
1154
+ tools on this port, so a client starts no process of its own. They call
1155
+ this server's own routes over loopback with the caller's credential and
1156
+ Host (both already checked above), so a key's collections and rate
1157
+ limit hold exactly as on /v1."""
1158
+ try:
1159
+ msg = json.loads(self.rfile.read(length))
1160
+ except (ValueError, RecursionError): # RecursionError: nesting deeper than json can parse
1161
+ return self._json(400, {"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "parse error"}})
1162
+ host, port = self.server.server_address[:2]
1163
+ host = {"0.0.0.0": "127.0.0.1", "::": "::1"}.get(host, host)
1164
+ sent = {h: self.headers[h] for h in ("Authorization", "Host", *guard.KEY_HEADERS) if self.headers.get(h)}
1165
+ conn = (f"http://{'[' + host + ']' if ':' in host else host}:{port}", sent)
1166
+ if isinstance(msg, dict) and msg.get("method") == "tools/call" and "id" in msg \
1167
+ and "text/event-stream" in (self.headers.get("Accept") or ""):
1168
+ return self._mcp_stream(msg, conn)
1169
+ reply = mcp_server.answer(msg, conn)
1170
+ if reply is None: # a notification: accepted, nothing to say
1171
+ self.send_response(202)
1172
+ self.send_header("Content-Length", "0")
1173
+ return self.end_headers()
1174
+ self._json(200, reply)
1175
+
1176
+ def _mcp_stream(self, msg: dict, conn: mcp_server.Conn) -> None:
1177
+ """A tool call as an SSE stream: the 200 at once, then every KEEPALIVE
1178
+ seconds while a panel runs (minutes) a progress notification when the
1179
+ client gave a progressToken, else a keep-alive comment, then the reply.
1180
+ A comment keeps proxies' idle timeouts away; only progress resets a
1181
+ client's own (Claude Code aborts a tool silent for 300 seconds)."""
1182
+ done: queue.Queue = queue.Queue()
1183
+ threading.Thread(target=lambda: done.put(mcp_server.answer(msg, conn)), daemon=True).start()
1184
+ params = msg.get("params") if isinstance(msg.get("params"), dict) else {}
1185
+ token = (params.get("_meta") or {}).get("progressToken") if isinstance(params.get("_meta"), dict) else None
1186
+ started = time.monotonic()
1187
+ try:
1188
+ self.send_response(200)
1189
+ self.send_header("Content-Type", "text/event-stream")
1190
+ self.send_header("Cache-Control", "no-cache")
1191
+ self.end_headers()
1192
+ while True:
1193
+ try:
1194
+ reply = done.get(timeout=KEEPALIVE)
1195
+ except queue.Empty:
1196
+ if token is None:
1197
+ self.wfile.write(b": keep-alive\n\n")
1198
+ else:
1199
+ seconds = round(time.monotonic() - started)
1200
+ self.wfile.write(b"event: message\ndata: " + json.dumps(
1201
+ {"jsonrpc": "2.0", "method": "notifications/progress",
1202
+ "params": {"progressToken": token, "progress": seconds,
1203
+ "message": f"the panel is running, {seconds} s"}}).encode() + b"\n\n")
1204
+ self.wfile.flush()
1205
+ continue
1206
+ self.wfile.write(b"event: message\ndata: " + json.dumps(reply).encode() + b"\n\n")
1207
+ return
1208
+ except OSError:
1209
+ return # the caller left; the panel still finishes, every seat recorded
1210
+
1102
1211
  def _show(self, body: dict) -> None:
1103
1212
  """POST /api/show: Ollama's model card, for a collection the key may call."""
1104
1213
  name = formats.ollama_model(body.get("model") or body.get("name") or "")
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/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.1",
3
+ "version": "0.3.3",
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())
@@ -96,7 +96,24 @@ def bench_field() -> None:
96
96
  srv.shutdown()
97
97
 
98
98
 
99
+ def records() -> None:
100
+ """GET /bench/record's counts: one collection's judged seats, a task's by its notes, never an unjudged one."""
101
+ for call, model, coll, useful, note in [("r1.m1", "m1", "bench/vision", True, "thicken slab faces: agreed"),
102
+ ("r1.m2", "m2", "bench/vision", False, "thicken slab faces: no answer"),
103
+ ("r2.m1", "m1", "bench/vision", True, "thicken what it is: agreed"),
104
+ ("r3.m1", "m1", "bench/core", False, "a review")]:
105
+ offload.add(model, f"look at this ({coll}, vision)", None, "member; ok", call)
106
+ offload.verdict(call, useful, note)
107
+ offload.add("m3", "look at this (bench/vision, vision)", None, "member; ok", "r4.m3") # never judged
108
+ assert gateway.record("bench/vision", "thicken slab faces") == {
109
+ "m1": {"useful": 1, "notUseful": 0, "judged": 1, "lastAt": offload.entries()[-1]["date"]},
110
+ "m2": {"useful": 0, "notUseful": 1, "judged": 1, "lastAt": offload.entries()[-1]["date"]}}
111
+ assert gateway.record("bench/vision")["m1"]["judged"] == 2 and "m3" not in gateway.record("bench/vision")
112
+ assert gateway.record("bench/core")["m1"]["notUseful"] == 1 and gateway.record("bench/none") == {}
113
+
114
+
99
115
  if __name__ == "__main__":
100
116
  verdicts()
101
117
  bench_field()
102
- print("verdict and bench-field checks pass")
118
+ records()
119
+ print("verdict, bench-field and record checks pass")