toolrank 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.
Files changed (56) hide show
  1. toolrank/__init__.py +20 -0
  2. toolrank/adapters/__init__.py +7 -0
  3. toolrank/adapters/backends.py +426 -0
  4. toolrank/adapters/bm25.py +78 -0
  5. toolrank/adapters/chat_api.py +69 -0
  6. toolrank/adapters/clm.py +169 -0
  7. toolrank/adapters/dense.py +103 -0
  8. toolrank/adapters/embeddings_api.py +221 -0
  9. toolrank/adapters/heads_np.py +220 -0
  10. toolrank/adapters/hybrid.py +68 -0
  11. toolrank/adapters/index_faiss.py +88 -0
  12. toolrank/adapters/index_numpy.py +141 -0
  13. toolrank/adapters/index_pgvector.py +97 -0
  14. toolrank/adapters/mcp_client.py +147 -0
  15. toolrank/adapters/mcp_proxy.py +391 -0
  16. toolrank/adapters/rest.py +594 -0
  17. toolrank/build.py +245 -0
  18. toolrank/cli.py +938 -0
  19. toolrank/client.py +193 -0
  20. toolrank/cut.py +85 -0
  21. toolrank/datasets/__init__.py +12 -0
  22. toolrank/datasets/jsonl.py +173 -0
  23. toolrank/datasets/livemcpbench.py +92 -0
  24. toolrank/datasets/mcp_zero.py +252 -0
  25. toolrank/datasets/synthetic.py +213 -0
  26. toolrank/datasets/toolret.py +117 -0
  27. toolrank/datasets/toolret_train.py +37 -0
  28. toolrank/domain.py +106 -0
  29. toolrank/eval/__init__.py +4 -0
  30. toolrank/eval/metrics.py +85 -0
  31. toolrank/eval/runner.py +159 -0
  32. toolrank/eval/table.py +227 -0
  33. toolrank/finetune.py +586 -0
  34. toolrank/formats.py +189 -0
  35. toolrank/ingest/__init__.py +5 -0
  36. toolrank/ingest/mcp.py +187 -0
  37. toolrank/ingest/openapi.py +352 -0
  38. toolrank/ingest/sync.py +154 -0
  39. toolrank/ingest/text.py +143 -0
  40. toolrank/integrations/__init__.py +15 -0
  41. toolrank/integrations/_common.py +40 -0
  42. toolrank/integrations/anthropic.py +285 -0
  43. toolrank/integrations/langgraph.py +202 -0
  44. toolrank/integrations/litellm.py +259 -0
  45. toolrank/integrations/llamaindex.py +190 -0
  46. toolrank/integrations/openai.py +250 -0
  47. toolrank/names.py +35 -0
  48. toolrank/ports.py +97 -0
  49. toolrank/retriever.py +348 -0
  50. toolrank/usage.py +232 -0
  51. toolrank-0.1.0.dist-info/METADATA +172 -0
  52. toolrank-0.1.0.dist-info/RECORD +56 -0
  53. toolrank-0.1.0.dist-info/WHEEL +4 -0
  54. toolrank-0.1.0.dist-info/entry_points.txt +2 -0
  55. toolrank-0.1.0.dist-info/licenses/LICENSE +202 -0
  56. toolrank-0.1.0.dist-info/licenses/NOTICE +41 -0
toolrank/__init__.py ADDED
@@ -0,0 +1,20 @@
1
+ """Toolrank: tool retrieval for LLM agents with hundreds of tools.
2
+
3
+ The package is organised as ports and adapters:
4
+
5
+ * ``toolrank.domain`` - plain dataclasses (Tool, Query, RankedList, EvalReport).
6
+ * ``toolrank.ports`` - Protocols every adapter implements (Scorer, TextEncoder, VectorIndex, ...).
7
+ * ``toolrank.adapters`` - BM25, dense (any OpenAI-compatible embeddings endpoint), heads, indexes,
8
+ the MCP proxy and REST API, MCP and OpenAPI backends.
9
+ * ``toolrank.ingest`` - MCP servers and OpenAPI specs -> an ingest dir (``tools.jsonl``).
10
+ * ``toolrank.retriever`` - query-time retrieval over an ingest dir, shared by search and serve.
11
+ * ``toolrank.client`` - a standard-library client for ``toolrank serve``'s REST API.
12
+ * ``toolrank.integrations`` - Claude's and OpenAI's tool search, LangGraph, LlamaIndex, LiteLLM.
13
+ * ``toolrank.datasets`` - benchmark converters and the on-disk JSONL format.
14
+ * ``toolrank.eval`` - trec_eval-compatible metrics and the benchmark runner.
15
+ """
16
+
17
+ from toolrank.domain import EvalReport, Query, RankedList, Tool
18
+
19
+ __all__ = ["EvalReport", "Query", "RankedList", "Tool", "__version__"]
20
+ __version__ = "0.1.0"
@@ -0,0 +1,7 @@
1
+ """Adapters: concrete Scorers and TextEncoders behind the ports in ``toolrank.ports``."""
2
+
3
+ from toolrank.adapters.bm25 import BM25Scorer
4
+ from toolrank.adapters.dense import DenseScorer
5
+ from toolrank.adapters.embeddings_api import EmbeddingCache, OpenAIEmbeddings
6
+
7
+ __all__ = ["BM25Scorer", "DenseScorer", "EmbeddingCache", "OpenAIEmbeddings"]
@@ -0,0 +1,426 @@
1
+ """Where ``call_tool`` goes: MCP servers (long-lived client sessions) and OpenAPI operations (HTTP).
2
+
3
+ MCP: one ``MCPBackend`` per configured server. Its task, started on the first call inside the
4
+ proxy's task group (``Backends.running()``), owns the ``mcp.Client``: connect (bounded by
5
+ ``connect_timeout``), list the tools once, stay connected, and after a failure wait for the next
6
+ caller before reconnecting (with backoff). Callers use the session directly with
7
+ ``read_timeout_seconds``, so a timed-out call stops there, and a call in flight when the backend
8
+ failed is never resent. Every failure becomes an ``isError`` result naming the server; the
9
+ backend's ``serverInfo`` stamp is removed so clients see the proxy's.
10
+
11
+ OpenAPI: ``OpenAPIExecutor`` turns a tool's ``doc["http"]`` routing and the agent's arguments into
12
+ one HTTP request (``httpx2``): path parameters percent-encoded, query parameters by their OpenAPI
13
+ style (``form`` explode → repeated keys, ``deepObject`` → ``k[sub]=v`` / ``k[0]=v``), booleans as
14
+ ``true``/``false``, JSON bodies, and form bodies nested with brackets the way Stripe reads them.
15
+ Safety: only GET and HEAD unless ``allow_write``; configured headers (credentials) go only to the
16
+ configured base URL (a path that does not start with ``/`` is refused: appended to the base URL it
17
+ could name another host; and the composed URL must have the base URL's origin); redirects are not followed; responses are cut at ``max_chars``. Multipart,
18
+ header and cookie parameters are refused for now.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import math
24
+ import re
25
+ import tempfile
26
+ import urllib.parse
27
+ from collections.abc import AsyncIterator, Sequence
28
+ from contextlib import AsyncExitStack, asynccontextmanager
29
+ from dataclasses import dataclass
30
+ from typing import Any
31
+
32
+ from toolrank.domain import Tool
33
+ from toolrank.ingest.mcp import OpenAPIBackend, ServerConfig
34
+
35
+ SAFE_METHODS = ("GET", "HEAD")
36
+ MAX_RESPONSE_CHARS = 25_000
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class CallOutcome:
41
+ result: Any # mcp_types.CallToolResult
42
+ outcome: str # one of toolrank.usage.OUTCOMES
43
+ http_status: int | None = None
44
+
45
+
46
+ class Refused(ValueError):
47
+ """The proxy will not make this call (policy or an unsupported shape), whatever the backend says."""
48
+
49
+
50
+ def error_result(text: str) -> Any:
51
+ from mcp_types import CallToolResult, TextContent
52
+
53
+ return CallToolResult(content=[TextContent(type="text", text=text)], is_error=True)
54
+
55
+
56
+ def _strip_server_info(result: Any) -> Any:
57
+ from mcp_types import SERVER_INFO_META_KEY
58
+
59
+ meta = getattr(result, "meta", None)
60
+ if isinstance(meta, dict) and SERVER_INFO_META_KEY in meta:
61
+ rest = {k: v for k, v in meta.items() if k != SERVER_INFO_META_KEY}
62
+ return result.model_copy(update={"meta": rest or None})
63
+ return result
64
+
65
+
66
+ # -- MCP --------------------------------------------------------------------------------------
67
+ class MCPBackend:
68
+ """One MCP server behind the proxy. State changes happen under ``_cond``: callers raise
69
+ ``_want`` and wait until a connection exists or an attempt that started after them failed; the
70
+ worker connects when wanted. A caller that sees the transport break sets that connection's own
71
+ ``broken`` event, so a late failure never tears down a newer connection."""
72
+
73
+ def __init__(self, cfg: ServerConfig, *, connect_timeout: float = 60.0):
74
+ self.cfg, self.connect_timeout = cfg, connect_timeout
75
+ self._session: tuple[Any, Any] | None = None # (client, its broken event)
76
+ self._error: str | None = None
77
+ self._delay = 0.0
78
+ self._want = False
79
+ self._attempt = 0 # attempts started
80
+ self._failed = 0 # the latest attempt that failed
81
+ self._started = False
82
+
83
+ def _start(self, tg: Any) -> None:
84
+ if self._started:
85
+ return
86
+ import anyio
87
+
88
+ self._cond = anyio.Condition()
89
+ self._started = True
90
+ tg.start_soon(self._run, name=f"toolrank-backend-{self.cfg.name}")
91
+
92
+ async def _run(self) -> None:
93
+ import shutil
94
+
95
+ import anyio
96
+ import httpx2
97
+ from mcp import Client
98
+
99
+ from toolrank.adapters.mcp_client import _reason, _stderr_tail, _transport, list_all
100
+
101
+ while True:
102
+ async with self._cond:
103
+ while not self._want:
104
+ await self._cond.wait()
105
+ self._want = False
106
+ self._attempt += 1
107
+ attempt = self._attempt
108
+ if self._delay:
109
+ await anyio.sleep(self._delay)
110
+ failure = None
111
+ broken = anyio.Event()
112
+ with tempfile.TemporaryFile("w+", encoding="utf-8") as errlog:
113
+ try:
114
+ if self.cfg.transport == "stdio" and shutil.which(self.cfg.command) is None:
115
+ raise FileNotFoundError(f"command not found: {self.cfg.command}")
116
+ # the deadline bounds the connection only; it is lifted once connected
117
+ with anyio.CancelScope(deadline=anyio.current_time() + self.connect_timeout) as scope:
118
+ async with AsyncExitStack() as stack:
119
+ http = None
120
+ if self.cfg.transport == "http":
121
+ http = await stack.enter_async_context(
122
+ httpx2.AsyncClient(
123
+ headers=dict(self.cfg.headers), timeout=httpx2.Timeout(30, read=None)
124
+ )
125
+ )
126
+ client = await stack.enter_async_context(
127
+ Client(_transport(self.cfg, errlog, http), cache=None)
128
+ )
129
+ await list_all(client)
130
+ scope.deadline = math.inf
131
+ async with self._cond:
132
+ self._session, self._error, self._delay = (client, broken), None, 0.0
133
+ self._cond.notify_all()
134
+ await broken.wait()
135
+ if scope.cancelled_caught:
136
+ failure = _reason(self.cfg, TimeoutError(), self.connect_timeout) + _stderr_tail(
137
+ errlog
138
+ )
139
+ except FileNotFoundError as e:
140
+ failure = f"{self.cfg.name}: {e}"
141
+ except Exception as e: # the SDK raises exception groups, transport and validation errors
142
+ failure = _reason(self.cfg, e, self.connect_timeout) + _stderr_tail(errlog)
143
+ async with self._cond:
144
+ self._session = None
145
+ if failure:
146
+ self._error, self._failed = failure, attempt
147
+ self._delay = min(max(self._delay * 2, 1.0), 30.0)
148
+ self._cond.notify_all()
149
+
150
+ async def _connection(self) -> tuple[Any, Any] | None:
151
+ import anyio
152
+
153
+ session = self._session
154
+ if session is not None:
155
+ return session
156
+ async with self._cond:
157
+ self._want = True
158
+ first = self._attempt + 1 # the next attempt to start is (also) ours
159
+ self._cond.notify_all()
160
+ with anyio.move_on_after(self.connect_timeout + self._delay + 5):
161
+ while self._session is None and self._failed < first:
162
+ await self._cond.wait()
163
+ return self._session
164
+
165
+ async def call(self, tg: Any, name: str, arguments: dict[str, Any], *, timeout: float) -> CallOutcome:
166
+ from mcp.shared.exceptions import MCPError
167
+ from mcp_types import CONNECTION_CLOSED, REQUEST_TIMEOUT
168
+
169
+ self._start(tg)
170
+ server = self.cfg.name
171
+ session = await self._connection()
172
+ if session is None:
173
+ return CallOutcome(error_result(self._error or f"{server}: not connected yet"), "protocol_error")
174
+ client, broken = session
175
+ try:
176
+ result = await client.call_tool(name, arguments, read_timeout_seconds=timeout)
177
+ except MCPError as e:
178
+ if e.code == REQUEST_TIMEOUT:
179
+ return CallOutcome(
180
+ error_result(f"{server}: {name} did not answer within {timeout:g} s"), "timeout"
181
+ )
182
+ if e.code == CONNECTION_CLOSED:
183
+ self._drop(session)
184
+ return CallOutcome(
185
+ error_result(f"{server}: the connection closed during the call; it was not retried"),
186
+ "protocol_error",
187
+ )
188
+ return CallOutcome(error_result(f"{server}: {e.message}"), "protocol_error")
189
+ except (RuntimeError, ValueError) as e: # output-schema checks, validation: the session is fine
190
+ return CallOutcome(error_result(f"{server}: {type(e).__name__}: {e}"), "protocol_error")
191
+ except Exception as e: # a broken transport: reconnect on the next call
192
+ self._drop(session)
193
+ return CallOutcome(
194
+ error_result(f"{server}: {type(e).__name__}: {e}; the call was not retried"), "protocol_error"
195
+ )
196
+ return CallOutcome(_strip_server_info(result), "tool_error" if result.is_error else "ok")
197
+
198
+ def _drop(self, session: tuple[Any, Any]) -> None:
199
+ """Retire ``session`` (only if it is still current); the next caller reconnects."""
200
+ if self._session is session:
201
+ self._session = None
202
+ session[1].set()
203
+
204
+
205
+ # -- OpenAPI ----------------------------------------------------------------------------------
206
+ def _scalar(v: Any) -> str:
207
+ if isinstance(v, bool):
208
+ return "true" if v else "false"
209
+ return "" if v is None else str(v)
210
+
211
+
212
+ def _bracket(prefix: str, value: Any) -> list[tuple[str, str]]:
213
+ """``{"a": {"b": 1}, "c": [2]}`` -> ``a[b]=1``, ``c[0]=2`` (Stripe's nesting, deepObject)."""
214
+ if isinstance(value, dict):
215
+ return [p for k, v in value.items() for p in _bracket(f"{prefix}[{k}]", v)]
216
+ if isinstance(value, list):
217
+ return [p for i, v in enumerate(value) for p in _bracket(f"{prefix}[{i}]", v)]
218
+ return [(prefix, _scalar(value))]
219
+
220
+
221
+ def _query_pairs(name: str, value: Any, style: str, explode: bool) -> list[tuple[str, str]]:
222
+ if style == "deepObject":
223
+ return _bracket(name, value)
224
+ if isinstance(value, list):
225
+ if style == "form" and explode:
226
+ return [(name, _scalar(v)) for v in value]
227
+ sep = {"spaceDelimited": " ", "pipeDelimited": "|"}.get(style, ",")
228
+ return [(name, sep.join(_scalar(v) for v in value))]
229
+ if isinstance(value, dict):
230
+ if style == "form" and explode:
231
+ return [(str(k), _scalar(v)) for k, v in value.items()]
232
+ return [(name, ",".join(f"{k},{_scalar(v)}" for k, v in value.items()))]
233
+ return [(name, _scalar(value))]
234
+
235
+
236
+ def same_origin(url: str, base: str) -> bool:
237
+ """Whether a request to ``url`` goes to ``base``'s scheme, host and port, as the HTTP client
238
+ reads the two: the guard that keeps a source's headers (credentials) on its own base URL."""
239
+ import httpx2
240
+
241
+ try:
242
+ a, b = httpx2.URL(url), httpx2.URL(base)
243
+ except httpx2.InvalidURL:
244
+ return False
245
+ return (a.scheme, a.host, a.port) == (b.scheme, b.host, b.port)
246
+
247
+
248
+ class OpenAPIExecutor:
249
+ def __init__(
250
+ self,
251
+ configs: dict[str, OpenAPIBackend],
252
+ *,
253
+ allow_write: bool = False,
254
+ timeout: float = 60.0,
255
+ transport: Any = None,
256
+ max_chars: int = MAX_RESPONSE_CHARS,
257
+ ):
258
+ self.configs, self.allow_write, self.timeout = configs, allow_write, timeout
259
+ self.transport, self.max_chars = transport, max_chars
260
+ self._client: Any = None
261
+
262
+ def build(self, tool: Tool, arguments: dict[str, Any] | None) -> dict[str, Any]:
263
+ """The request (``httpx2.AsyncClient.request`` keyword arguments) for one call; raises
264
+ ``Refused`` for policy and unsupported shapes, ``ValueError`` for bad arguments."""
265
+ http = tool.doc.get("http") or {}
266
+ method = str(http.get("method") or "GET").upper()
267
+ source = tool.category
268
+ if method not in SAFE_METHODS and not self.allow_write:
269
+ raise Refused(
270
+ f"{tool.id} is a {method} operation; this proxy only sends GET and HEAD "
271
+ "(start it with --allow-write to allow changes)"
272
+ )
273
+ cfg = self.configs.get(source)
274
+ base = cfg.base_url if cfg is not None and cfg.base_url else str(http.get("base_url") or "")
275
+ if not re.match(r"https?://", base):
276
+ raise Refused(
277
+ f"{source}: no absolute base URL ({base!r}); set openapi.{source}.base_url in the config"
278
+ )
279
+ headers = dict(cfg.headers) if cfg is not None and cfg.base_url else {}
280
+ spec = http.get("args") or {}
281
+ path = str(http.get("path") or "")
282
+ if not path.startswith("/"): # "@host/x" or ".host/x" after the base URL names another host
283
+ raise Refused(f"{tool.id}: the path {path!r} does not start with '/'")
284
+ query: list[tuple[str, str]] = []
285
+ body: dict[str, Any] = {}
286
+ whole, has_whole = None, False
287
+ for key, value in (arguments or {}).items():
288
+ arg = spec.get(key)
289
+ if arg is None:
290
+ raise ValueError(f"{tool.id} has no argument {key!r}")
291
+ where, name = arg.get("in"), str(arg.get("name", key))
292
+ if where == "path":
293
+ text = _scalar(value)
294
+ if text in (".", ".."): # a dot segment would move the request up the path
295
+ raise ValueError(f"{tool.id}: path parameter {name!r} cannot be {text!r}")
296
+ path = path.replace("{" + name + "}", urllib.parse.quote(text, safe=""))
297
+ elif where == "query":
298
+ if isinstance(value, (list, dict)) and "style" not in arg:
299
+ raise Refused(f"{tool.id}: re-ingest the spec to pass a list or an object in {key!r}")
300
+ query += _query_pairs(
301
+ name, value, str(arg.get("style", "form")), bool(arg.get("explode", True))
302
+ )
303
+ elif where == "body":
304
+ if name:
305
+ body[name] = value
306
+ else:
307
+ whole, has_whole = value, True
308
+ else:
309
+ raise Refused(f"{tool.id}: {where} parameters are not supported by the proxy yet")
310
+ missing = re.findall(r"\{([^}]+)\}", path)
311
+ if missing:
312
+ raise ValueError(f"{tool.id} is missing path parameter(s): {', '.join(missing)}")
313
+ url = base.rstrip("/") + path
314
+ if not same_origin(url, base): # the URL that is sent, however it came to be composed
315
+ raise Refused(f"{tool.id}: {url!r} is not a URL on {base}")
316
+ req: dict[str, Any] = {"method": method, "url": url, "params": query, "headers": headers}
317
+ payload = whole if has_whole else (body or None)
318
+ if payload is not None:
319
+ media = str(http.get("body_media_type") or "application/json")
320
+ if "json" in media:
321
+ req["json"] = payload
322
+ elif media == "application/x-www-form-urlencoded" and isinstance(payload, dict):
323
+ pairs = [p for k, v in payload.items() for p in _bracket(k, v)]
324
+ req["content"] = urllib.parse.urlencode(pairs).encode("utf-8")
325
+ req["headers"] = {**headers, "Content-Type": media}
326
+ else:
327
+ raise Refused(f"{tool.id}: {media} bodies are not supported by the proxy yet")
328
+ return req
329
+
330
+ def _http(self) -> Any:
331
+ import httpx2
332
+
333
+ if self._client is None:
334
+ self._client = httpx2.AsyncClient(
335
+ timeout=httpx2.Timeout(self.timeout), follow_redirects=False, transport=self.transport
336
+ )
337
+ return self._client
338
+
339
+ async def aclose(self) -> None:
340
+ if self._client is not None:
341
+ await self._client.aclose()
342
+ self._client = None
343
+
344
+ async def call(self, tool: Tool, arguments: dict[str, Any] | None) -> CallOutcome:
345
+ import httpx2
346
+ from mcp_types import CallToolResult, TextContent
347
+
348
+ try:
349
+ req = self.build(tool, arguments)
350
+ except Refused as e:
351
+ return CallOutcome(error_result(str(e)), "refused")
352
+ except ValueError as e:
353
+ return CallOutcome(error_result(str(e)), "tool_error")
354
+ try:
355
+ resp = await self._http().request(**req)
356
+ except httpx2.TimeoutException:
357
+ return CallOutcome(
358
+ error_result(f"{tool.category}: no answer within {self.timeout:g} s"), "timeout"
359
+ )
360
+ except httpx2.HTTPError as e:
361
+ return CallOutcome(error_result(f"{tool.category}: {type(e).__name__}: {e}"), "protocol_error")
362
+ status = resp.status_code
363
+ if 300 <= status < 400:
364
+ text = f"HTTP {status}: redirect to {resp.headers.get('location', '?')} (not followed)"
365
+ else:
366
+ body = resp.text
367
+ if len(body) > self.max_chars:
368
+ body = body[: self.max_chars] + f"\n… truncated: {len(body)} characters in total"
369
+ text = f"HTTP {status}\n{body}"
370
+ failed = status >= 300
371
+ result = CallToolResult(content=[TextContent(type="text", text=text)], is_error=failed)
372
+ return CallOutcome(result, "tool_error" if failed else "ok", status)
373
+
374
+
375
+ # -- router -----------------------------------------------------------------------------------
376
+ class Backends:
377
+ """``call_tool``'s router: MCP tools to their server's session, OpenAPI tools over HTTP."""
378
+
379
+ def __init__(
380
+ self,
381
+ servers: Sequence[ServerConfig] = (),
382
+ openapi: dict[str, OpenAPIBackend] | None = None,
383
+ *,
384
+ allow_write: bool = False,
385
+ call_timeout: float = 60.0,
386
+ connect_timeout: float = 60.0,
387
+ transport: Any = None,
388
+ ):
389
+ self.mcp = {c.name: MCPBackend(c, connect_timeout=connect_timeout) for c in servers}
390
+ self.http = OpenAPIExecutor(
391
+ openapi or {}, allow_write=allow_write, timeout=call_timeout, transport=transport
392
+ )
393
+ self.call_timeout = call_timeout
394
+ self._tg: Any = None
395
+
396
+ @asynccontextmanager
397
+ async def running(self) -> AsyncIterator[Backends]:
398
+ """The task group the MCP sessions live in; leaving it closes every backend."""
399
+ import anyio
400
+
401
+ try:
402
+ async with anyio.create_task_group() as tg:
403
+ self._tg = tg
404
+ try:
405
+ yield self
406
+ finally:
407
+ tg.cancel_scope.cancel()
408
+ finally:
409
+ self._tg = None
410
+ await self.http.aclose()
411
+
412
+ async def call(self, tool: Tool, arguments: dict[str, Any] | None) -> CallOutcome:
413
+ if "http" in tool.doc:
414
+ return await self.http.call(tool, arguments)
415
+ backend = self.mcp.get(tool.category)
416
+ if backend is None:
417
+ return CallOutcome(
418
+ error_result(
419
+ f"{tool.category}: this MCP server is not configured for the proxy (--config or --server)"
420
+ ),
421
+ "refused",
422
+ )
423
+ if self._tg is None:
424
+ raise RuntimeError("Backends.call() outside Backends.running()")
425
+ name = str(tool.doc.get("name") or tool.id)
426
+ return await backend.call(self._tg, name, dict(arguments or {}), timeout=self.call_timeout)
@@ -0,0 +1,78 @@
1
+ """Lexical baseline: BM25 via ``bm25s`` (the ToolRet paper's "BM25s" row).
2
+
3
+ Tokenisation: lowercase, English stopwords, optional Snowball stemming when ``PyStemmer`` is
4
+ installed (``pip install toolrank[stem]``). Anthropic's Tool Search Tool BM25 variant and most
5
+ gateways' "keyword search" are this class of model, so this row is the floor every learned
6
+ scorer must clear.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import threading
12
+ from collections.abc import Iterable, Sequence
13
+
14
+ import numpy as np
15
+
16
+ from toolrank.domain import Query, RankedList, Tool
17
+ from toolrank.formats import QUERY_FORMATS, TOOL_FORMATS, NamedFormatter
18
+
19
+
20
+ def _stemmer(lang: str = "english"):
21
+ try:
22
+ import Stemmer # PyStemmer
23
+
24
+ return Stemmer.Stemmer(lang)
25
+ except ImportError:
26
+ return None
27
+
28
+
29
+ class BM25Scorer:
30
+ name = "bm25"
31
+ score_kind = "bm25"
32
+
33
+ def __init__(
34
+ self,
35
+ tool_format: NamedFormatter | str = "documentation",
36
+ query_format: NamedFormatter | str = "plain",
37
+ *,
38
+ k1: float = 1.5,
39
+ b: float = 0.75,
40
+ stem: bool = True,
41
+ stopwords: str | None = "en",
42
+ ):
43
+ self.tool_format = TOOL_FORMATS[tool_format] if isinstance(tool_format, str) else tool_format
44
+ self.query_format = QUERY_FORMATS[query_format] if isinstance(query_format, str) else query_format
45
+ self.k1, self.b, self.stopwords = k1, b, stopwords
46
+ self.stemmer = _stemmer() if stem else None
47
+ self.name = f"bm25/{self.tool_format.name}/{self.query_format.name}" + (
48
+ "" if self.stemmer else "/nostem"
49
+ )
50
+ self._ids: list[str] = []
51
+ self._bm25 = None
52
+ self._lock = threading.Lock() # a server ranks from several threads; the stemmer is not shared-safe
53
+
54
+ def index(self, tools: Iterable[Tool]) -> None:
55
+ import bm25s
56
+
57
+ tools = list(tools)
58
+ self._ids = [t.id for t in tools]
59
+ texts = [self.tool_format(t) for t in tools]
60
+ toks = bm25s.tokenize(texts, stopwords=self.stopwords, stemmer=self.stemmer, show_progress=False)
61
+ self._bm25 = bm25s.BM25(k1=self.k1, b=self.b)
62
+ self._bm25.index(toks, show_progress=False)
63
+
64
+ def rank(self, queries: Sequence[Query], k: int) -> list[RankedList]:
65
+ import bm25s
66
+
67
+ if self._bm25 is None:
68
+ raise RuntimeError("call index() before rank()")
69
+ texts = [self.query_format(q) for q in queries]
70
+ kk = min(k, len(self._ids))
71
+ with self._lock:
72
+ toks = bm25s.tokenize(texts, stopwords=self.stopwords, stemmer=self.stemmer, show_progress=False)
73
+ docs, scores = self._bm25.retrieve(toks, k=kk, show_progress=False, n_threads=1)
74
+ docs, scores = np.asarray(docs), np.asarray(scores, dtype=np.float32)
75
+ return [
76
+ RankedList(q.id, [self._ids[int(j)] for j in docs[i]], [float(s) for s in scores[i]])
77
+ for i, q in enumerate(queries)
78
+ ]
@@ -0,0 +1,69 @@
1
+ """A ``ChatModel`` over any OpenAI-compatible ``/v1/chat/completions`` endpoint.
2
+
3
+ Used to generate benchmark queries (MCP-Zero) with a local model, e.g. ``vllm serve
4
+ Qwen/Qwen3-8B`` in the default (generate) runner. Greedy by default so a rerun asks the same
5
+ questions. ``extra_body`` is merged into every request for server-specific switches, such as
6
+ ``{"chat_template_kwargs": {"enable_thinking": False}}`` to turn off Qwen3's thinking on vLLM.
7
+
8
+ Standard library only (``urllib``), like the embeddings adapter.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import time
15
+ import urllib.error
16
+ import urllib.request
17
+ from typing import Any
18
+
19
+ from toolrank.adapters.embeddings_api import env_api_key
20
+
21
+
22
+ class OpenAIChat:
23
+ def __init__(
24
+ self,
25
+ model: str,
26
+ base_url: str = "http://127.0.0.1:8093/v1",
27
+ *,
28
+ api_key: str | None = None,
29
+ temperature: float = 0.0,
30
+ max_tokens: int = 256,
31
+ extra_body: dict[str, Any] | None = None,
32
+ timeout: float = 300.0,
33
+ max_retries: int = 3,
34
+ ):
35
+ self.model, self.base_url = model, base_url.rstrip("/")
36
+ self.api_key = api_key or env_api_key(self.base_url, "TOOLRANK_CHAT_API_KEY")
37
+ self.temperature, self.max_tokens = temperature, max_tokens
38
+ self.extra_body = dict(extra_body or {})
39
+ self.timeout, self.max_retries = timeout, max_retries
40
+ self.name = f"chat/{model}"
41
+
42
+ def complete(self, system: str, user: str) -> str:
43
+ body: dict[str, Any] = {
44
+ "model": self.model,
45
+ "messages": [{"role": "system", "content": system}, {"role": "user", "content": user}],
46
+ "temperature": self.temperature,
47
+ "max_tokens": self.max_tokens,
48
+ **self.extra_body,
49
+ }
50
+ req = urllib.request.Request(
51
+ f"{self.base_url}/chat/completions",
52
+ data=json.dumps(body).encode("utf-8"),
53
+ headers={"Content-Type": "application/json"},
54
+ method="POST",
55
+ )
56
+ if self.api_key: # never copied onto a redirect, which may point anywhere
57
+ req.add_unredirected_header("Authorization", f"Bearer {self.api_key}")
58
+ last: Exception | None = None
59
+ for attempt in range(self.max_retries):
60
+ try:
61
+ with urllib.request.urlopen(req, timeout=self.timeout) as r:
62
+ j = json.loads(r.read().decode("utf-8"))
63
+ break
64
+ except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError) as e: # noqa: PERF203
65
+ last = e
66
+ time.sleep(1.5 * (attempt + 1))
67
+ else:
68
+ raise RuntimeError(f"chat endpoint {self.base_url} unreachable: {last}") from last
69
+ return str(j["choices"][0]["message"].get("content") or "")