toolnexus 0.24.0 → 0.25.0

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 (55) hide show
  1. package/README.md +116 -1
  2. package/dist/acp.d.ts +37 -6
  3. package/dist/acp.js +239 -52
  4. package/dist/client.d.ts +32 -8
  5. package/dist/client.js +102 -23
  6. package/dist/index.d.ts +2 -0
  7. package/dist/index.js +2 -0
  8. package/dist/mcp.d.ts +32 -7
  9. package/dist/mcp.js +125 -36
  10. package/dist/mcp2026/auth.d.ts +137 -0
  11. package/dist/mcp2026/auth.js +446 -0
  12. package/dist/mcp2026/client.d.ts +48 -0
  13. package/dist/mcp2026/client.js +160 -0
  14. package/dist/mcp2026/protocol.d.ts +54 -0
  15. package/dist/mcp2026/protocol.js +113 -0
  16. package/dist/mcp2026/skills.d.ts +5 -0
  17. package/dist/mcp2026/skills.js +25 -0
  18. package/dist/mcp2026/source.d.ts +86 -0
  19. package/dist/mcp2026/source.js +372 -0
  20. package/dist/mcp2026/stdio.d.ts +24 -0
  21. package/dist/mcp2026/stdio.js +99 -0
  22. package/dist/mcp2026/toolsearch.d.ts +5 -0
  23. package/dist/mcp2026/toolsearch.js +58 -0
  24. package/dist/mcphandler.d.ts +169 -0
  25. package/dist/mcphandler.js +1145 -0
  26. package/dist/mcpheader.d.ts +5 -0
  27. package/dist/mcpheader.js +18 -0
  28. package/dist/mcpserve.d.ts +24 -15
  29. package/dist/mcpserve.js +105 -2
  30. package/dist/mcpserverauth.d.ts +48 -0
  31. package/dist/mcpserverauth.js +203 -0
  32. package/dist/skill.d.ts +21 -0
  33. package/dist/skill.js +44 -0
  34. package/dist/toolkit.d.ts +20 -1
  35. package/dist/toolkit.js +25 -3
  36. package/dist/types.d.ts +22 -0
  37. package/package.json +4 -3
  38. package/src/acp.ts +258 -55
  39. package/src/client.ts +124 -27
  40. package/src/index.ts +2 -0
  41. package/src/mcp.ts +162 -43
  42. package/src/mcp2026/auth.ts +473 -0
  43. package/src/mcp2026/client.ts +161 -0
  44. package/src/mcp2026/protocol.ts +135 -0
  45. package/src/mcp2026/skills.ts +22 -0
  46. package/src/mcp2026/source.ts +407 -0
  47. package/src/mcp2026/stdio.ts +102 -0
  48. package/src/mcp2026/toolsearch.ts +56 -0
  49. package/src/mcphandler.ts +1252 -0
  50. package/src/mcpheader.ts +19 -0
  51. package/src/mcpserve.ts +115 -2
  52. package/src/mcpserverauth.ts +216 -0
  53. package/src/skill.ts +50 -0
  54. package/src/toolkit.ts +31 -4
  55. package/src/types.ts +23 -0
@@ -0,0 +1,372 @@
1
+ /**
2
+ * The 2026-07-28 direct path behind loadMcp (SPEC §2.2–§2.8, §2A): discover first, then tools,
3
+ * skills and tool search; MRTR and OAuth pauses surface through §10 (waitFor inline, or a
4
+ * `{status:"pending"}` suspension resumed via `ctx.answer`). `probe` reports `fallback` when the
5
+ * server is pre-2026 so loadMcp takes the frozen SDK path (§2.F).
6
+ */
7
+ import { randomBytes } from "node:crypto";
8
+ import { Mcp2026Client } from "./client.js";
9
+ import { HttpStatusError, NotJsonRpcError, PROTOCOL_VERSION, RpcError, SKILLS_EXT } from "./protocol.js";
10
+ import { LoopbackAuthorizer, MemoryTokenStore, OAuth, PauseAuthorizer, PendingError } from "./auth.js";
11
+ import { spawnStdio, StdioClosedError } from "./stdio.js";
12
+ import { rank } from "./toolsearch.js";
13
+ import { sanitize } from "../types.js";
14
+ import { answerToElicitResult, elicitationToRequest, mapCallToolResult, mcpHints } from "../mcp.js";
15
+ /** The OAuth engine for one remote server (§2A.1). */
16
+ export function newOAuth(auth, o, store) {
17
+ const a = auth ?? {};
18
+ const mode = a.mode ?? "auto";
19
+ let authorizer;
20
+ if (mode === "pause") {
21
+ if (!a.redirectUri)
22
+ throw new Error("auth.redirectUri is required for pause mode");
23
+ authorizer = new PauseAuthorizer({ redirect: a.redirectUri });
24
+ }
25
+ else {
26
+ authorizer = new LoopbackAuthorizer(o.openUrl ? { open: o.openUrl } : {});
27
+ }
28
+ return new OAuth({
29
+ authorizer, store,
30
+ clientId: a.clientId, clientSecret: a.clientSecret, tokenAuthMethod: a.tokenAuthMethod,
31
+ clientIds: a.clientIds, clientMetadataUrl: a.clientMetadataUrl,
32
+ omitRefreshResource: a.omitRefreshResource, allowHttp: a.allowHttp,
33
+ });
34
+ }
35
+ export function defaultTokenStore(o) {
36
+ return o.tokenStore ?? new MemoryTokenStore();
37
+ }
38
+ function deniedError(a) {
39
+ return new Error(`authorization denied: ${a?.reason ?? "cancelled"}`);
40
+ }
41
+ function callbackUrlOf(a) {
42
+ const u = a.data?.callbackUrl;
43
+ if (typeof u !== "string" || !u)
44
+ throw new Error("authorization answer carries no data.callbackUrl");
45
+ return u;
46
+ }
47
+ /** Runs f; an OAuth pause is answered inline through waitFor, else it is a load failure. */
48
+ async function authed(oauth, waitFor, f) {
49
+ try {
50
+ return await f();
51
+ }
52
+ catch (e) {
53
+ if (!(e instanceof PendingError) || !oauth)
54
+ throw e;
55
+ if (!waitFor)
56
+ throw new Error("authorization pending");
57
+ const a = await waitFor({ ...e.pending });
58
+ if (!a?.ok)
59
+ throw deniedError(a);
60
+ // resume() stores the token (and re-sends the paused read); f() then runs again, authorized.
61
+ await oauth.resume(e.pending.id, callbackUrlOf(a));
62
+ return f();
63
+ }
64
+ }
65
+ /** §2.2a: which discover failures mean "old server". */
66
+ function isFallbackError(e) {
67
+ if (e instanceof RpcError)
68
+ return true;
69
+ if (e instanceof HttpStatusError)
70
+ return e.status === 400 || e.status === 404 || e.status === 405 || e.status >= 500;
71
+ if (e instanceof NotJsonRpcError)
72
+ return true;
73
+ return false;
74
+ }
75
+ function supports2026(r) {
76
+ return Array.isArray(r.supportedVersions) && r.supportedVersions.includes(PROTOCOL_VERSION);
77
+ }
78
+ /** Discover first (§2.2). Resolves `modern` (tools listed) or `fallback`; throws on a load failure. */
79
+ export async function probe(t, o) {
80
+ let client;
81
+ if (t.remote) {
82
+ client = new Mcp2026Client(t.remote.url, { headers: t.remote.headers, auth: t.remote.oauth, timeoutMs: t.timeout });
83
+ }
84
+ else {
85
+ const tr = await spawnStdio(t.local.argv, t.local.env, t.local.cwd);
86
+ client = new Mcp2026Client("", { transport: tr, timeoutMs: t.timeout });
87
+ }
88
+ const oauth = t.remote?.oauth;
89
+ let disc;
90
+ try {
91
+ // stdio: an old server may ignore a pre-initialize request ⇒ min(timeout, 5 s) then fall back.
92
+ const timeoutMs = t.local ? Math.min(t.timeout, 5000) : t.timeout;
93
+ disc = await authed(oauth, o.waitFor, () => client.call("server/discover", {}, { timeoutMs, signal: o.signal }));
94
+ }
95
+ catch (e) {
96
+ const stdioTimeout = !!t.local && e instanceof Error && e.message === "stdio request timed out";
97
+ await client.close().catch(() => { });
98
+ if (o.signal?.aborted)
99
+ throw e;
100
+ if (e instanceof StdioClosedError)
101
+ throw e;
102
+ if (isFallbackError(e) || stdioTimeout)
103
+ return { kind: "fallback" };
104
+ throw e;
105
+ }
106
+ if (!supports2026(disc)) {
107
+ await client.close().catch(() => { });
108
+ return { kind: "fallback" };
109
+ }
110
+ try {
111
+ const server = new ModernServer(t, client, disc, o);
112
+ await server.load();
113
+ return { kind: "modern", server };
114
+ }
115
+ catch (e) {
116
+ await client.close().catch(() => { });
117
+ throw e;
118
+ }
119
+ }
120
+ /** §2 Gap 7 filter over raw defs (same semantics as the frozen path). */
121
+ function filterDefs(server, defs, filter) {
122
+ if (!filter)
123
+ return defs;
124
+ const keys = Object.keys(filter);
125
+ if (keys.length === 0)
126
+ return defs;
127
+ const hasTrue = keys.some((k) => filter[k] === true);
128
+ const present = new Set(defs.map((d) => d.name));
129
+ for (const k of keys)
130
+ if (!present.has(k))
131
+ console.warn(`[toolnexus] server "${server}" tools filter name "${k}" matched no tool`);
132
+ return defs.filter((d) => (hasTrue ? filter[d.name] === true : filter[d.name] !== false));
133
+ }
134
+ function parseFrontmatter(md) {
135
+ const m = md.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
136
+ if (!m)
137
+ return { data: {}, body: md };
138
+ const data = {};
139
+ for (const line of m[1].split(/\r?\n/)) {
140
+ if (/^\s/.test(line))
141
+ continue;
142
+ const i = line.indexOf(":");
143
+ if (i <= 0)
144
+ continue;
145
+ let v = line.slice(i + 1).trim();
146
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'")))
147
+ v = v.slice(1, -1);
148
+ data[line.slice(0, i).trim()] = v;
149
+ }
150
+ return { data, body: m[2] };
151
+ }
152
+ const MAX_ROUNDS = 8;
153
+ export class ModernServer {
154
+ t;
155
+ o;
156
+ client;
157
+ discover;
158
+ defs = [];
159
+ skills = [];
160
+ all = [];
161
+ cacheUntil = Infinity;
162
+ pend = new Map();
163
+ constructor(t, client, disc, o) {
164
+ this.t = t;
165
+ this.o = o;
166
+ this.client = client;
167
+ this.discover = disc;
168
+ }
169
+ get oauth() {
170
+ return this.t.remote?.oauth;
171
+ }
172
+ req(f) {
173
+ return authed(this.oauth, this.o.waitFor, f);
174
+ }
175
+ async load() {
176
+ await this.refreshTools();
177
+ if (this.discover.capabilities?.extensions?.[SKILLS_EXT] !== undefined)
178
+ await this.loadSkills();
179
+ }
180
+ async refreshTools() {
181
+ const r = await this.req(() => this.client.listTools({ signal: this.o.signal }));
182
+ this.all = r.tools;
183
+ const off = new Set(this.o.disableTools ?? []);
184
+ const prefix = sanitize(this.t.name) + "_";
185
+ this.defs = filterDefs(this.t.name, r.tools, this.t.tools).filter((d) => !off.has(prefix + sanitize(d.name)));
186
+ this.cacheUntil = typeof r.ttlMs === "number" ? Date.now() + r.ttlMs : Infinity;
187
+ }
188
+ async loadSkills() {
189
+ const r = await this.req(() => this.client.call("skills/list", {}, { signal: this.o.signal }));
190
+ for (const s of Array.isArray(r.skills) ? r.skills : []) {
191
+ const uri = String(s?.uri ?? "");
192
+ try {
193
+ const f = await this.req(() => this.client.fetchSkill(uri, { signal: this.o.signal }));
194
+ const { data, body } = parseFrontmatter(f.markdown);
195
+ const name = data.name || f.name;
196
+ this.skills.push({ name, description: data.description ?? "", content: body, resources: f.resources, base: `skill://${f.name}/` });
197
+ }
198
+ catch (e) {
199
+ console.warn(`[toolnexus] MCP server "${this.t.name}" skill ${JSON.stringify(uri)} skipped: ${e instanceof Error ? e.message : String(e)}`);
200
+ }
201
+ }
202
+ }
203
+ /** The toolkit tools for this server: every (filtered) server tool, or the two tool-search tools. */
204
+ tools() {
205
+ const server = this.t.name;
206
+ if (!this.t.toolSearch)
207
+ return this.defs.map((d) => this.convert(d));
208
+ const about = this.t.description || server;
209
+ const search = {
210
+ name: sanitize(server) + "_search_tools",
211
+ description: `Search the tools of ${about} by keywords. Returns name, description and inputSchema of the best matches; run one with call_tool.`,
212
+ inputSchema: { type: "object", properties: { query: { type: "string" }, limit: { type: "integer" } }, required: ["query"] },
213
+ source: "mcp",
214
+ execute: async (args, ctx) => {
215
+ try {
216
+ if (Date.now() >= this.cacheUntil)
217
+ await this.refreshTools();
218
+ const limit = typeof args?.limit === "number" && args.limit > 0 ? Math.trunc(args.limit) : 5;
219
+ const hits = rank(this.defs, String(args?.query ?? ""), limit).map((d) => ({
220
+ name: d.name, description: d.description, inputSchema: d.inputSchema, server: this.t.description ?? "",
221
+ }));
222
+ return { output: JSON.stringify(hits), isError: false, metadata: { server } };
223
+ }
224
+ catch (e) {
225
+ return { output: e instanceof Error ? e.message : String(e), isError: true, metadata: { server } };
226
+ }
227
+ },
228
+ };
229
+ const call = {
230
+ name: sanitize(server) + "_call_tool",
231
+ description: "Run a tool found with search_tools.",
232
+ inputSchema: { type: "object", properties: { name: { type: "string" }, arguments: { type: "object" } }, required: ["name"] },
233
+ source: "mcp",
234
+ execute: async (args, ctx) => {
235
+ const name = String(args?.name ?? "");
236
+ const a = args?.arguments && typeof args.arguments === "object" && !Array.isArray(args.arguments) ? args.arguments : {};
237
+ if (!(ctx?.answer && this.pend.has(ctx.answer.id)) && !this.defs.some((d) => d.name === name)) {
238
+ return { output: `unknown tool "${name}" on ${server}`, isError: true, metadata: { server } };
239
+ }
240
+ return this.run(name, a, ctx);
241
+ },
242
+ };
243
+ return [search, call];
244
+ }
245
+ convert(def) {
246
+ const inputSchema = {
247
+ ...def.inputSchema,
248
+ type: "object",
249
+ properties: (def.inputSchema?.properties ?? {}),
250
+ additionalProperties: false,
251
+ };
252
+ const hints = mcpHints(def.annotations);
253
+ return {
254
+ name: sanitize(this.t.name) + "_" + sanitize(def.name),
255
+ description: def.description ?? "",
256
+ inputSchema,
257
+ source: "mcp",
258
+ ...(hints ? { hints } : {}),
259
+ execute: (args, ctx) => this.run(def.name, args ?? {}, ctx),
260
+ };
261
+ }
262
+ /** tools/call with MRTR rounds (§2.5) and OAuth pauses (§2A.1 10); never re-sends after a failure (§2.6). */
263
+ async run(name, args, ctx) {
264
+ const server = this.t.name;
265
+ try {
266
+ const answer = ctx?.answer;
267
+ const cont = answer ? this.pend.get(answer.id) : undefined;
268
+ if (cont && answer) {
269
+ this.pend.delete(answer.id);
270
+ return await this.drive(cont, answer, ctx);
271
+ }
272
+ return await this.drive({ name, args, round: 0, reqs: {}, keys: [], idx: 0 }, undefined, ctx);
273
+ }
274
+ catch (e) {
275
+ return { output: e instanceof Error ? e.message : String(e), isError: true, metadata: { server } };
276
+ }
277
+ }
278
+ suspend(cont, req) {
279
+ this.pend.set(req.id, cont);
280
+ const output = req.url ? `${req.prompt}: ${req.url}` : req.prompt;
281
+ return { output, isError: true, metadata: { pending: req, server: this.t.name } };
282
+ }
283
+ async drive(cont, answer, ctx) {
284
+ const waitFor = this.o.waitFor;
285
+ let res;
286
+ if (answer) {
287
+ if (cont.authId) {
288
+ if (!answer.ok)
289
+ throw deniedError(answer);
290
+ const id = cont.authId;
291
+ cont.authId = undefined;
292
+ res = await this.oauth.resume(id, callbackUrlOf(answer));
293
+ }
294
+ else {
295
+ cont.responses[cont.keys[cont.idx]] = answerToElicitResult(answer);
296
+ cont.idx++;
297
+ }
298
+ }
299
+ for (;;) {
300
+ if (!res) {
301
+ while (cont.idx < cont.keys.length) {
302
+ const key = cont.keys[cont.idx];
303
+ const req = this.toRequest(key, cont.reqs[key]);
304
+ if (!waitFor)
305
+ return this.suspend(cont, req);
306
+ cont.responses[key] = answerToElicitResult(await waitFor(req));
307
+ cont.idx++;
308
+ }
309
+ if (cont.round >= MAX_ROUNDS)
310
+ throw new Error(`tools/call ${cont.name}: too many input_required rounds`);
311
+ const p = { name: cont.name, arguments: cont.args };
312
+ if (cont.responses)
313
+ p.inputResponses = cont.responses;
314
+ if (cont.state !== undefined && cont.state !== null)
315
+ p.requestState = cont.state;
316
+ try {
317
+ res = await this.client.call("tools/call", p, { timeoutMs: ctx?.timeout, signal: ctx?.signal });
318
+ }
319
+ catch (e) {
320
+ if (!(e instanceof PendingError) || !this.oauth)
321
+ throw e;
322
+ // Rejected before dispatch (401/403): this round has NOT run. Resume sends it once.
323
+ if (!waitFor) {
324
+ cont.authId = e.pending.id;
325
+ return this.suspend(cont, { ...e.pending });
326
+ }
327
+ const a = await waitFor({ ...e.pending });
328
+ if (!a?.ok)
329
+ throw deniedError(a);
330
+ res = await this.oauth.resume(e.pending.id, callbackUrlOf(a));
331
+ }
332
+ }
333
+ if (res.resultType !== "input_required")
334
+ return mapCallToolResult(res, this.t.name);
335
+ const reqs = res.inputRequests && typeof res.inputRequests === "object" && !Array.isArray(res.inputRequests) ? res.inputRequests : {};
336
+ for (const [k, v] of Object.entries(reqs)) {
337
+ if (!v || typeof v !== "object" || Array.isArray(v))
338
+ throw new Error(`tools/call ${cont.name}: malformed input request "${k}"`);
339
+ }
340
+ cont.round++;
341
+ cont.state = res.requestState;
342
+ cont.responses = {};
343
+ cont.reqs = reqs;
344
+ cont.keys = Object.keys(reqs);
345
+ cont.idx = 0;
346
+ res = undefined;
347
+ }
348
+ }
349
+ toRequest(key, ir) {
350
+ if (ir.method !== "elicitation/create")
351
+ throw new Error(`unsupported input request method ${JSON.stringify(ir.method)} ("${key}")`);
352
+ const req = elicitationToRequest(ir.params ?? {});
353
+ req.id = "mrtr-" + randomBytes(9).toString("base64url");
354
+ return req;
355
+ }
356
+ /** resources/list for list_mcp_resources (§4C). */
357
+ async listResources(signal) {
358
+ const r = await this.req(() => this.client.call("resources/list", {}, { signal }));
359
+ return Array.isArray(r.resources) ? r.resources : [];
360
+ }
361
+ async readResource(uri, signal) {
362
+ const r = await this.req(() => this.client.call("resources/read", { uri }, { signal }));
363
+ return Array.isArray(r.contents) ? r.contents : [];
364
+ }
365
+ /** Raw (unfiltered) tool defs, for listMcpTools. */
366
+ rawDefs() {
367
+ return this.all;
368
+ }
369
+ close() {
370
+ return this.client.close();
371
+ }
372
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * stdio transport for the 2026 path (§2.3): one JSON-RPC message per line, responses demultiplexed
3
+ * by id (concurrent calls allowed), stderr passed through and never parsed, a dead child fails every
4
+ * pending call instead of hanging. No headers, no auth.
5
+ */
6
+ import { type ChildProcess } from "node:child_process";
7
+ import type { Transport } from "./client.js";
8
+ import type { Obj } from "./protocol.js";
9
+ /** The child exited / closed stdout before answering. */
10
+ export declare class StdioClosedError extends Error {
11
+ }
12
+ export declare class StdioTransport implements Transport {
13
+ private pending;
14
+ private err?;
15
+ private done;
16
+ private child;
17
+ constructor(child: ChildProcess);
18
+ /** Writes one line and waits for the response with the same id. Never retried. */
19
+ roundTrip(id: number, body: string, timeoutMs: number, signal?: AbortSignal): Promise<Obj>;
20
+ /** Close stdin, then SIGKILL after 2 s if the child is still alive. */
21
+ close(): Promise<void>;
22
+ }
23
+ /** Spawns argv (no shell) and returns its transport; rejects if the spawn itself fails. */
24
+ export declare function spawnStdio(argv: string[], env: NodeJS.ProcessEnv, cwd?: string): Promise<StdioTransport>;
@@ -0,0 +1,99 @@
1
+ /**
2
+ * stdio transport for the 2026 path (§2.3): one JSON-RPC message per line, responses demultiplexed
3
+ * by id (concurrent calls allowed), stderr passed through and never parsed, a dead child fails every
4
+ * pending call instead of hanging. No headers, no auth.
5
+ */
6
+ import { spawn } from "node:child_process";
7
+ import { createInterface } from "node:readline";
8
+ /** The child exited / closed stdout before answering. */
9
+ export class StdioClosedError extends Error {
10
+ }
11
+ export class StdioTransport {
12
+ pending = new Map();
13
+ err;
14
+ done;
15
+ child;
16
+ constructor(child) {
17
+ this.child = child;
18
+ const rl = createInterface({ input: child.stdout, crlfDelay: Infinity });
19
+ rl.on("line", (line) => {
20
+ let m;
21
+ try {
22
+ m = JSON.parse(line);
23
+ }
24
+ catch {
25
+ return; // noise
26
+ }
27
+ if (!m || typeof m !== "object" || m.method !== undefined || m.id === undefined || m.id === null)
28
+ return; // not a response
29
+ const key = JSON.stringify(m.id);
30
+ const f = this.pending.get(key);
31
+ this.pending.delete(key);
32
+ f?.(m);
33
+ });
34
+ this.done = new Promise((r) => rl.once("close", () => {
35
+ this.err ??= new StdioClosedError("stdio server closed its stdout");
36
+ for (const f of this.pending.values())
37
+ f({ __closed: true });
38
+ this.pending.clear();
39
+ r();
40
+ }));
41
+ child.stdin?.on("error", () => { }); // EPIPE after the server died surfaces as a closed stdout
42
+ }
43
+ /** Writes one line and waits for the response with the same id. Never retried. */
44
+ roundTrip(id, body, timeoutMs, signal) {
45
+ if (this.err)
46
+ return Promise.reject(this.err);
47
+ const key = JSON.stringify(id);
48
+ return new Promise((resolve, reject) => {
49
+ const cleanup = () => {
50
+ clearTimeout(timer);
51
+ signal?.removeEventListener("abort", onAbort);
52
+ this.pending.delete(key);
53
+ };
54
+ const onAbort = () => {
55
+ cleanup();
56
+ reject(signal?.reason ?? new Error("aborted"));
57
+ };
58
+ const timer = setTimeout(() => {
59
+ cleanup();
60
+ reject(new Error("stdio request timed out"));
61
+ }, timeoutMs);
62
+ if (signal) {
63
+ if (signal.aborted)
64
+ return onAbort();
65
+ signal.addEventListener("abort", onAbort, { once: true });
66
+ }
67
+ this.pending.set(key, (m) => {
68
+ cleanup();
69
+ if (m.__closed)
70
+ return reject(this.err);
71
+ if (m.result == null && m.error == null)
72
+ return reject(new Error("response has neither result nor error"));
73
+ resolve(m);
74
+ });
75
+ this.child.stdin?.write(body + "\n");
76
+ });
77
+ }
78
+ /** Close stdin, then SIGKILL after 2 s if the child is still alive. */
79
+ async close() {
80
+ if (this.child.exitCode === null && this.child.signalCode === null) {
81
+ this.child.stdin?.end();
82
+ const exited = new Promise((r) => this.child.once("exit", () => r()));
83
+ const t = setTimeout(() => this.child.kill("SIGKILL"), 2000);
84
+ await exited;
85
+ clearTimeout(t);
86
+ }
87
+ this.child.stdout?.destroy();
88
+ await this.done;
89
+ }
90
+ }
91
+ /** Spawns argv (no shell) and returns its transport; rejects if the spawn itself fails. */
92
+ export function spawnStdio(argv, env, cwd) {
93
+ const [cmd, ...args] = argv;
94
+ const child = spawn(cmd, args, { env, cwd, stdio: ["pipe", "pipe", "inherit"] });
95
+ return new Promise((resolve, reject) => {
96
+ child.once("error", reject);
97
+ child.once("spawn", () => resolve(new StdioTransport(child)));
98
+ });
99
+ }
@@ -0,0 +1,5 @@
1
+ import type { Obj } from "./protocol.js";
2
+ /** BM25 (k1 1.2, b 0.75) over name tokens twice + description tokens; best first, ties keep list order. */
3
+ export declare function rank(tools: Obj[], query: string, limit: number): Obj[];
4
+ /** Lowercases and splits on non-alphanumerics and camelCase boundaries. */
5
+ export declare function tokens(s: string): string[];
@@ -0,0 +1,58 @@
1
+ /** BM25 (k1 1.2, b 0.75) over name tokens twice + description tokens; best first, ties keep list order. */
2
+ export function rank(tools, query, limit) {
3
+ const docs = tools.map((t) => {
4
+ const nt = tokens(String(t.name ?? ""));
5
+ return [...nt, ...nt, ...tokens(String(t.description ?? ""))];
6
+ });
7
+ const df = new Map();
8
+ let total = 0;
9
+ for (const d of docs) {
10
+ total += d.length;
11
+ for (const w of new Set(d))
12
+ df.set(w, (df.get(w) ?? 0) + 1);
13
+ }
14
+ const avg = total / Math.max(1, tools.length);
15
+ const qs = tokens(query);
16
+ const hits = [];
17
+ docs.forEach((d, i) => {
18
+ const tf = new Map();
19
+ for (const w of d)
20
+ tf.set(w, (tf.get(w) ?? 0) + 1);
21
+ let s = 0;
22
+ for (const q of qs) {
23
+ const f = tf.get(q) ?? 0;
24
+ if (f === 0)
25
+ continue;
26
+ const n = df.get(q) ?? 0;
27
+ const idf = Math.log(1 + (tools.length - n + 0.5) / (n + 0.5));
28
+ s += (idf * f * 2.2) / (f + 1.2 * (0.25 + (0.75 * d.length) / avg));
29
+ }
30
+ if (s > 0)
31
+ hits.push({ i, score: s });
32
+ });
33
+ hits.sort((a, b) => b.score - a.score); // stable
34
+ return hits.slice(0, Math.max(0, limit)).map((h) => tools[h.i]);
35
+ }
36
+ /** Lowercases and splits on non-alphanumerics and camelCase boundaries. */
37
+ export function tokens(s) {
38
+ const out = [];
39
+ let cur = "";
40
+ let prevLower = false;
41
+ for (const r of s) {
42
+ if (/\p{Lu}/u.test(r) && prevLower) {
43
+ if (cur)
44
+ out.push(cur.toLowerCase());
45
+ cur = r;
46
+ }
47
+ else if (/[\p{L}\p{N}]/u.test(r))
48
+ cur += r;
49
+ else if (cur) {
50
+ out.push(cur.toLowerCase());
51
+ cur = "";
52
+ }
53
+ prevLower = /\p{Ll}/u.test(r);
54
+ }
55
+ if (cur)
56
+ out.push(cur.toLowerCase());
57
+ return out;
58
+ }