toolnexus 0.8.0 → 0.9.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.
package/dist/client.d.ts CHANGED
@@ -32,6 +32,18 @@ export interface ClientOptions {
32
32
  * (open a browser, message a channel, watch a file, forward to another agent). Omit for a durable
33
33
  * host: run() does not hang — it returns { status:"pending", pending:request } to resume later. */
34
34
  waitFor?: (request: Request) => Promise<Answer>;
35
+ /** §8 Gap 1. Extra top-level keys shallow-merged into EVERY LLM body after the client builds
36
+ * its own — a requestParams key WINS on collision (e.g. max_tokens overrides the anthropic
37
+ * default). `messages`/`tools`/`stream` are forbidden here (stripped + warned) — rewrite
38
+ * messages via bodyTransform. Applied on all four paths. Omit ⇒ body byte-identical to today. */
39
+ requestParams?: Record<string, unknown>;
40
+ /** §8 Gap 1. Receives the fully assembled body (after requestParams merge) just before marshal
41
+ * and returns the body to send — the escape hatch for provider-specific rewriting (e.g. tool-role
42
+ * message preprocessing) without a proxy. Returning a falsy value ⇒ unchanged. Runs on every call. */
43
+ bodyTransform?: (body: Record<string, any>) => Record<string, any> | undefined | void;
44
+ /** §8 Gap 2. HTTP transport for LLM requests (retries included). Omit ⇒ global fetch. Scope is the
45
+ * LLM path only; MCP transports use the SDK's own client. */
46
+ fetch?: typeof fetch;
35
47
  }
36
48
  /**
37
49
  * Semantic observability events (§8). NOT counter/histogram primitives — readable records the
@@ -191,6 +203,23 @@ export declare class Client {
191
203
  /** Cumulative Prometheus registry fed by the same events `onMetric` sees. */
192
204
  private readonly registry;
193
205
  constructor(opts: ClientOptions);
206
+ /**
207
+ * §8 Gap 4. The client's conversation store — the exact instance passed in `opts.store`, else
208
+ * the default in-memory store the client created. Read (`get`) and write (`save`) it directly to
209
+ * share state with `ask`; no consumer needs a shadow copy.
210
+ */
211
+ conversationStore(): ConversationStore;
212
+ /**
213
+ * §8 Gap 1. Apply the request-shaping contract to an assembled body: strip forbidden keys from
214
+ * requestParams, shallow-merge requestParams (caller wins), then run bodyTransform last. Called at
215
+ * every LLM body-build site so the ordering contract holds identically on all four paths.
216
+ */
217
+ private finalizeBody;
218
+ /** §8 Gap 5. OpenAI-style body; omits tools/tool_choice when the tool list is empty. Key order
219
+ * matches the pre-change body so a no-options call is byte-identical. */
220
+ private openaiBody;
221
+ /** §8 Gap 5. Anthropic-style body; omits tools when the tool list is empty. */
222
+ private anthropicBody;
194
223
  /** Prometheus text exposition of cumulative metrics (§8). Always valid, empty-but-valid before activity. */
195
224
  metrics(): string;
196
225
  /** Feed a semantic metric event to the built-in registry and the optional onMetric sink. */
package/dist/client.js CHANGED
@@ -150,6 +150,60 @@ export class Client {
150
150
  this.opts = opts;
151
151
  this.store = opts.store ?? new InMemoryConversationStore();
152
152
  }
153
+ /**
154
+ * §8 Gap 4. The client's conversation store — the exact instance passed in `opts.store`, else
155
+ * the default in-memory store the client created. Read (`get`) and write (`save`) it directly to
156
+ * share state with `ask`; no consumer needs a shadow copy.
157
+ */
158
+ conversationStore() {
159
+ return this.store;
160
+ }
161
+ /**
162
+ * §8 Gap 1. Apply the request-shaping contract to an assembled body: strip forbidden keys from
163
+ * requestParams, shallow-merge requestParams (caller wins), then run bodyTransform last. Called at
164
+ * every LLM body-build site so the ordering contract holds identically on all four paths.
165
+ */
166
+ finalizeBody(body) {
167
+ const rp = this.opts.requestParams;
168
+ if (rp) {
169
+ for (const [k, v] of Object.entries(rp)) {
170
+ if (k === "messages" || k === "tools" || k === "stream") {
171
+ console.warn(`[toolnexus] requestParams key "${k}" is not allowed — ignored (use bodyTransform)`);
172
+ continue;
173
+ }
174
+ body[k] = v;
175
+ }
176
+ }
177
+ if (this.opts.bodyTransform) {
178
+ const out = this.opts.bodyTransform(body);
179
+ if (out)
180
+ return out;
181
+ }
182
+ return body;
183
+ }
184
+ /** §8 Gap 5. OpenAI-style body; omits tools/tool_choice when the tool list is empty. Key order
185
+ * matches the pre-change body so a no-options call is byte-identical. */
186
+ openaiBody(messages, tools, stream) {
187
+ const b = { model: this.opts.model, messages };
188
+ if (tools.length) {
189
+ b.tools = tools;
190
+ b.tool_choice = "auto";
191
+ }
192
+ if (stream) {
193
+ b.stream = true;
194
+ b.stream_options = { include_usage: true };
195
+ }
196
+ return this.finalizeBody(b);
197
+ }
198
+ /** §8 Gap 5. Anthropic-style body; omits tools when the tool list is empty. */
199
+ anthropicBody(system, messages, tools, stream) {
200
+ const b = { model: this.opts.model, max_tokens: 4096, system, messages };
201
+ if (tools.length)
202
+ b.tools = tools;
203
+ if (stream)
204
+ b.stream = true;
205
+ return this.finalizeBody(b);
206
+ }
153
207
  /** Prometheus text exposition of cumulative metrics (§8). Always valid, empty-but-valid before activity. */
154
208
  metrics() {
155
209
  return this.registry.render();
@@ -244,7 +298,7 @@ export class Client {
244
298
  let lastErr;
245
299
  for (let attempt = 0; attempt <= retries; attempt++) {
246
300
  try {
247
- const res = await fetch(url, { ...init, signal });
301
+ const res = await (this.opts.fetch ?? fetch)(url, { ...init, signal });
248
302
  if (res.ok || !RETRYABLE.has(res.status) || attempt === retries)
249
303
  return res;
250
304
  const ra = Number(res.headers.get("retry-after"));
@@ -341,7 +395,7 @@ export class Client {
341
395
  const data = await this.llmCallJson(`${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`, {
342
396
  method: "POST",
343
397
  headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", ...this.opts.headers },
344
- body: JSON.stringify({ model: this.opts.model, messages, tools, tool_choice: "auto" }),
398
+ body: JSON.stringify(this.openaiBody(messages, tools, false)),
345
399
  }, signal, "openai");
346
400
  addUsage(usage, data.usage, "openai");
347
401
  if (this.opts.hooks?.afterLLM)
@@ -442,7 +496,7 @@ export class Client {
442
496
  "Content-Type": "application/json",
443
497
  ...this.opts.headers,
444
498
  },
445
- body: JSON.stringify({ model: this.opts.model, max_tokens: 4096, system, messages, tools }),
499
+ body: JSON.stringify(this.anthropicBody(system, messages, tools, false)),
446
500
  }, signal, "anthropic");
447
501
  addUsage(usage, data.usage, "anthropic");
448
502
  if (this.opts.hooks?.afterLLM)
@@ -523,7 +577,7 @@ export class Client {
523
577
  const res = await this.llmFetch(`${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`, {
524
578
  method: "POST",
525
579
  headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", ...this.opts.headers },
526
- body: JSON.stringify({ model: this.opts.model, messages, tools, tool_choice: "auto", stream: true, stream_options: { include_usage: true } }),
580
+ body: JSON.stringify(this.openaiBody(messages, tools, true)),
527
581
  }, signal);
528
582
  if (!res.ok || !res.body)
529
583
  throw new Error(`LLM ${res.status}: ${await res.text()}`);
@@ -630,7 +684,7 @@ export class Client {
630
684
  const res = await this.llmFetch(endpoint, {
631
685
  method: "POST",
632
686
  headers: { "x-api-key": key, "anthropic-version": "2023-06-01", "Content-Type": "application/json", ...this.opts.headers },
633
- body: JSON.stringify({ model: this.opts.model, max_tokens: 4096, system, messages, tools, stream: true }),
687
+ body: JSON.stringify(this.anthropicBody(system, messages, tools, true)),
634
688
  }, signal);
635
689
  if (!res.ok || !res.body)
636
690
  throw new Error(`LLM ${res.status}: ${await res.text()}`);
package/dist/mcp.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { ElicitRequest, ElicitResult } from "@modelcontextprotocol/sdk/types.js";
2
- import { type Tool, type McpStatus, type Request, type Answer } from "./types.js";
2
+ import { type JSONSchema, type Tool, type McpStatus, type Request, type Answer } from "./types.js";
3
3
  /** Map an MCP `elicitation/create` request onto a §10 `Request`. */
4
4
  export declare function elicitationToRequest(params: ElicitRequest["params"]): Request;
5
5
  /** Map a resolved §10 `Answer` back onto an MCP `ElicitResult`. ok→accept; declined→decline; else→cancel. */
@@ -13,6 +13,10 @@ export interface LocalServer {
13
13
  enabled?: boolean;
14
14
  disabled?: boolean;
15
15
  timeout?: number;
16
+ /** §2 Gap 7. Per-server tool allowlist keyed on ORIGINAL tool name; same semantics as the
17
+ * builtins/skills filter (nil/empty ⇒ all; ≥1 true ⇒ allowlist; only-false ⇒ drop-list; unknown
18
+ * ⇒ ignore+warn). Applied to the listed defs before sanitize/prefix. */
19
+ tools?: Record<string, boolean>;
16
20
  }
17
21
  export interface RemoteServer {
18
22
  type?: "remote";
@@ -21,6 +25,8 @@ export interface RemoteServer {
21
25
  enabled?: boolean;
22
26
  disabled?: boolean;
23
27
  timeout?: number;
28
+ /** §2 Gap 7. Per-server tool allowlist (see LocalServer.tools). */
29
+ tools?: Record<string, boolean>;
24
30
  }
25
31
  export type ServerConfig = LocalServer | RemoteServer;
26
32
  export type McpConfig = Record<string, ServerConfig>;
@@ -36,7 +42,29 @@ export interface McpSource {
36
42
  status: Record<string, McpStatus>;
37
43
  close(): Promise<void>;
38
44
  }
39
- /** Connect to every enabled server, list + convert tools. Failures are isolated. */
45
+ /** Connect to every enabled server, list + convert tools. Failures are isolated.
46
+ * §2 Gap 3: an optional `signal` bounds each connect/list by the server timeout and aborts the
47
+ * whole load promptly on parent cancellation. */
40
48
  export declare function loadMcp(input: string | object, opts?: {
41
49
  waitFor?: (request: Request) => Promise<Answer>;
50
+ signal?: AbortSignal;
42
51
  }): Promise<McpSource>;
52
+ /** §2 Gap 6. One listed tool definition (original, unprefixed name). */
53
+ export interface ToolInfo {
54
+ name: string;
55
+ description: string;
56
+ inputSchema: JSONSchema;
57
+ }
58
+ /** §2 Gap 6. Result of a list-only pass over an MCP config. */
59
+ export interface McpInventory {
60
+ /** server → its full listed tool defs, UNFILTERED by ServerConfig.tools (authors the allowlist). */
61
+ tools: Record<string, ToolInfo[]>;
62
+ status: Record<string, McpStatus>;
63
+ }
64
+ /**
65
+ * §2 Gap 6. Connect to every enabled server, list tool definitions, and DISCONNECT before
66
+ * returning — no toolkit, no execute wiring, nothing left running. Failure isolation as in loadMcp.
67
+ */
68
+ export declare function listMcpTools(input: string | object, opts?: {
69
+ signal?: AbortSignal;
70
+ }): Promise<McpInventory>;
package/dist/mcp.js CHANGED
@@ -99,6 +99,42 @@ function joinTextContent(content) {
99
99
  .flatMap((item) => (isTextContent(item) ? [item.text] : []))
100
100
  .join("\n");
101
101
  }
102
+ /**
103
+ * §2 Gap 7. Apply a per-server tool allowlist to the listed defs. Same semantics as the builtins
104
+ * and skills filters: nil/empty ⇒ all; ≥1 true ⇒ allowlist (only true names); only-false ⇒ drop-list
105
+ * over all-on; unknown names ignored + warned once. Keyed on ORIGINAL tool name.
106
+ */
107
+ function applyToolsFilter(server, defs, filter) {
108
+ if (!filter)
109
+ return defs;
110
+ const keys = Object.keys(filter);
111
+ if (keys.length === 0)
112
+ return defs;
113
+ const hasTrue = keys.some((k) => filter[k] === true);
114
+ const present = new Set(defs.map((d) => d.name));
115
+ for (const k of keys) {
116
+ if (!present.has(k))
117
+ console.warn(`[toolnexus] server "${server}" tools filter name "${k}" matched no tool`);
118
+ }
119
+ return defs.filter((d) => (hasTrue ? filter[d.name] === true : filter[d.name] !== false));
120
+ }
121
+ /**
122
+ * §2 Gap 3. Race a promise against a per-server timeout and the caller's abort signal, so a hung
123
+ * connect/list is bounded (the SSE fallback included) and a parent cancellation aborts promptly.
124
+ */
125
+ function raceTimeout(p, ms, signal) {
126
+ return new Promise((resolve, reject) => {
127
+ const onAbort = () => { cleanup(); reject(signal?.reason ?? new Error("aborted")); };
128
+ const t = setTimeout(() => { cleanup(); reject(new Error("mcp connect/list timeout")); }, ms);
129
+ const cleanup = () => { clearTimeout(t); signal?.removeEventListener("abort", onAbort); };
130
+ if (signal) {
131
+ if (signal.aborted)
132
+ return onAbort();
133
+ signal.addEventListener("abort", onAbort, { once: true });
134
+ }
135
+ p.then((v) => { cleanup(); resolve(v); }, (e) => { cleanup(); reject(e); });
136
+ });
137
+ }
102
138
  async function paginateTools(client, timeout) {
103
139
  const result = [];
104
140
  const cursors = new Set();
@@ -144,50 +180,66 @@ function convertTool(server, def, client, timeout) {
144
180
  },
145
181
  };
146
182
  }
147
- /** Connect to every enabled server, list + convert tools. Failures are isolated. */
183
+ /** Create a fresh (not-yet-connected) client, with the elicitation bridge attached if a waitFor
184
+ * exists. Separated from connect so the caller ALWAYS holds a closeable reference — even if the
185
+ * connect is later abandoned by a timeout/abort, `client.close()` still terminates any spawned
186
+ * stdio child (no orphaned processes on cancellation). */
187
+ function newMcpClient(waitFor) {
188
+ const client = new Client({ name: "toolnexus", version: "0.1.0" }, { capabilities: waitFor ? { elicitation: {} } : {} });
189
+ if (waitFor) {
190
+ client.setRequestHandler(ElicitRequestSchema, async (request) => answerToElicitResult(await waitFor(elicitationToRequest(request.params))));
191
+ }
192
+ return client;
193
+ }
194
+ /** Connect an existing client's transport (remote streamable-HTTP with SSE fallback, or local stdio). */
195
+ async function connectTransport(client, cfg) {
196
+ if (isRemote(cfg)) {
197
+ const url = new URL(cfg.url);
198
+ const expanded = expandEnvHeaders(cfg.headers);
199
+ const requestInit = expanded ? { headers: expanded } : undefined;
200
+ try {
201
+ await client.connect(new StreamableHTTPClientTransport(url, { requestInit }));
202
+ }
203
+ catch {
204
+ await client.connect(new SSEClientTransport(url, { requestInit }));
205
+ }
206
+ }
207
+ else {
208
+ const local = cfg;
209
+ const [cmd, ...args] = local.command;
210
+ await client.connect(new StdioClientTransport({
211
+ command: cmd,
212
+ args,
213
+ cwd: local.cwd,
214
+ env: { ...process.env, ...(local.environment ?? local.env ?? {}) },
215
+ stderr: "pipe",
216
+ }));
217
+ }
218
+ }
219
+ /** Connect to every enabled server, list + convert tools. Failures are isolated.
220
+ * §2 Gap 3: an optional `signal` bounds each connect/list by the server timeout and aborts the
221
+ * whole load promptly on parent cancellation. */
148
222
  export async function loadMcp(input, opts) {
149
223
  const config = parseMcpConfig(input);
150
224
  const tools = [];
151
225
  const status = {};
152
226
  const clients = [];
153
227
  const waitFor = opts?.waitFor;
228
+ const signal = opts?.signal;
154
229
  await Promise.all(Object.entries(config).map(async ([name, cfg]) => {
155
230
  if (!isEnabled(cfg)) {
156
231
  status[name] = "disabled";
157
232
  return;
158
233
  }
159
234
  const timeout = cfg.timeout ?? DEFAULT_TIMEOUT;
160
- // Advertise elicitation + register the bridge ONLY when a waitFor exists to satisfy it —
161
- // a waitFor-less host degrades cleanly (the server won't elicit). (§10 elicitation bridge)
162
- const client = new Client({ name: "toolnexus", version: "0.1.0" }, { capabilities: waitFor ? { elicitation: {} } : {} });
163
- if (waitFor) {
164
- client.setRequestHandler(ElicitRequestSchema, async (request) => answerToElicitResult(await waitFor(elicitationToRequest(request.params))));
165
- }
235
+ // Create the client up front so a timed-out/aborted connect still has a closeable handle
236
+ // (its stdio child is killed by close() — never orphaned).
237
+ const client = newMcpClient(waitFor);
166
238
  try {
167
- if (isRemote(cfg)) {
168
- const url = new URL(cfg.url);
169
- const expanded = expandEnvHeaders(cfg.headers);
170
- const requestInit = expanded ? { headers: expanded } : undefined;
171
- try {
172
- await client.connect(new StreamableHTTPClientTransport(url, { requestInit }));
173
- }
174
- catch {
175
- await client.connect(new SSEClientTransport(url, { requestInit }));
176
- }
177
- }
178
- else {
179
- const local = cfg;
180
- const [cmd, ...args] = local.command;
181
- await client.connect(new StdioClientTransport({
182
- command: cmd,
183
- args,
184
- cwd: local.cwd,
185
- env: { ...process.env, ...(local.environment ?? local.env ?? {}) },
186
- stderr: "pipe",
187
- }));
188
- }
189
- const defs = await paginateTools(client, timeout);
190
- for (const def of defs)
239
+ await raceTimeout(connectTransport(client, cfg), timeout, signal);
240
+ const defs = await raceTimeout(paginateTools(client, timeout), timeout, signal);
241
+ const kept = applyToolsFilter(name, defs, cfg.tools);
242
+ for (const def of kept)
191
243
  tools.push(convertTool(name, def, client, timeout));
192
244
  clients.push(client);
193
245
  status[name] = "connected";
@@ -195,7 +247,10 @@ export async function loadMcp(input, opts) {
195
247
  catch (e) {
196
248
  status[name] = "failed";
197
249
  await client.close().catch(() => { });
198
- // one bad server never breaks the toolkit
250
+ // Parent cancellation/deadline aborts the whole load (§2 Gap 3, A1); a per-server timeout
251
+ // within budget isolates just this server.
252
+ if (signal?.aborted)
253
+ throw e;
199
254
  console.warn(`[toolnexus] MCP server "${name}" failed: ${e instanceof Error ? e.message : String(e)}`);
200
255
  }
201
256
  }));
@@ -207,3 +262,48 @@ export async function loadMcp(input, opts) {
207
262
  },
208
263
  };
209
264
  }
265
+ /**
266
+ * §2 Gap 6. Connect to every enabled server, list tool definitions, and DISCONNECT before
267
+ * returning — no toolkit, no execute wiring, nothing left running. Failure isolation as in loadMcp.
268
+ */
269
+ export async function listMcpTools(input, opts) {
270
+ const config = parseMcpConfig(input);
271
+ const toolsByServer = {};
272
+ const status = {};
273
+ const signal = opts?.signal;
274
+ await Promise.all(Object.entries(config).map(async ([name, cfg]) => {
275
+ if (!isEnabled(cfg)) {
276
+ status[name] = "disabled";
277
+ return;
278
+ }
279
+ const timeout = cfg.timeout ?? DEFAULT_TIMEOUT;
280
+ // Client created up front so an aborted/timed-out connect is still closed (no orphaned child).
281
+ const client = newMcpClient();
282
+ try {
283
+ await raceTimeout(connectTransport(client, cfg), timeout, signal);
284
+ const defs = await raceTimeout(paginateTools(client, timeout), timeout, signal);
285
+ toolsByServer[name] = defs.map((d) => ({
286
+ name: d.name,
287
+ description: d.description ?? "",
288
+ inputSchema: {
289
+ ...d.inputSchema,
290
+ type: "object",
291
+ properties: (d.inputSchema?.properties ?? {}),
292
+ },
293
+ }));
294
+ status[name] = "connected";
295
+ }
296
+ catch (e) {
297
+ status[name] = "failed";
298
+ if (signal?.aborted) {
299
+ await client.close().catch(() => { });
300
+ throw e;
301
+ }
302
+ console.warn(`[toolnexus] MCP server "${name}" failed: ${e instanceof Error ? e.message : String(e)}`);
303
+ }
304
+ finally {
305
+ await client.close().catch(() => { });
306
+ }
307
+ }));
308
+ return { tools: toolsByServer, status };
309
+ }
package/dist/toolkit.d.ts CHANGED
@@ -32,6 +32,9 @@ export interface ToolkitOptions {
32
32
  * elicitation is not advertised.
33
33
  */
34
34
  waitFor?: (request: import("./types.js").Request) => Promise<import("./types.js").Answer>;
35
+ /** §2 Gap 3. Bounds the MCP load: each connect/list is capped by the server timeout and the
36
+ * whole load aborts promptly if this signal fires. Omit ⇒ unbounded as before. */
37
+ signal?: AbortSignal;
35
38
  }
36
39
  export declare class Toolkit {
37
40
  private readonly mcp;
package/dist/toolkit.js CHANGED
@@ -34,7 +34,7 @@ export class Toolkit {
34
34
  }
35
35
  }
36
36
  static async create(opts) {
37
- const mcp = opts.mcpConfig !== undefined ? await loadMcp(opts.mcpConfig, { waitFor: opts.waitFor }) : undefined;
37
+ const mcp = opts.mcpConfig !== undefined ? await loadMcp(opts.mcpConfig, { waitFor: opts.waitFor, signal: opts.signal }) : undefined;
38
38
  // Skills come from directories, in-memory data, and/or a lazy provider (§3,
39
39
  // S1). The provider is resolved here (create() is async) and merged with the
40
40
  // data list; a provider failure is isolated so other sources still load.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolnexus",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Dynamic MCP servers + agent skills as uniform tools for any LLM (opencode-style).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/client.ts CHANGED
@@ -35,6 +35,18 @@ export interface ClientOptions {
35
35
  * (open a browser, message a channel, watch a file, forward to another agent). Omit for a durable
36
36
  * host: run() does not hang — it returns { status:"pending", pending:request } to resume later. */
37
37
  waitFor?: (request: Request) => Promise<Answer>
38
+ /** §8 Gap 1. Extra top-level keys shallow-merged into EVERY LLM body after the client builds
39
+ * its own — a requestParams key WINS on collision (e.g. max_tokens overrides the anthropic
40
+ * default). `messages`/`tools`/`stream` are forbidden here (stripped + warned) — rewrite
41
+ * messages via bodyTransform. Applied on all four paths. Omit ⇒ body byte-identical to today. */
42
+ requestParams?: Record<string, unknown>
43
+ /** §8 Gap 1. Receives the fully assembled body (after requestParams merge) just before marshal
44
+ * and returns the body to send — the escape hatch for provider-specific rewriting (e.g. tool-role
45
+ * message preprocessing) without a proxy. Returning a falsy value ⇒ unchanged. Runs on every call. */
46
+ bodyTransform?: (body: Record<string, any>) => Record<string, any> | undefined | void
47
+ /** §8 Gap 2. HTTP transport for LLM requests (retries included). Omit ⇒ global fetch. Scope is the
48
+ * LLM path only; MCP transports use the SDK's own client. */
49
+ fetch?: typeof fetch
38
50
  }
39
51
 
40
52
  /**
@@ -281,6 +293,61 @@ export class Client {
281
293
  this.store = opts.store ?? new InMemoryConversationStore()
282
294
  }
283
295
 
296
+ /**
297
+ * §8 Gap 4. The client's conversation store — the exact instance passed in `opts.store`, else
298
+ * the default in-memory store the client created. Read (`get`) and write (`save`) it directly to
299
+ * share state with `ask`; no consumer needs a shadow copy.
300
+ */
301
+ conversationStore(): ConversationStore {
302
+ return this.store
303
+ }
304
+
305
+ /**
306
+ * §8 Gap 1. Apply the request-shaping contract to an assembled body: strip forbidden keys from
307
+ * requestParams, shallow-merge requestParams (caller wins), then run bodyTransform last. Called at
308
+ * every LLM body-build site so the ordering contract holds identically on all four paths.
309
+ */
310
+ private finalizeBody(body: Record<string, any>): Record<string, any> {
311
+ const rp = this.opts.requestParams
312
+ if (rp) {
313
+ for (const [k, v] of Object.entries(rp)) {
314
+ if (k === "messages" || k === "tools" || k === "stream") {
315
+ console.warn(`[toolnexus] requestParams key "${k}" is not allowed — ignored (use bodyTransform)`)
316
+ continue
317
+ }
318
+ body[k] = v
319
+ }
320
+ }
321
+ if (this.opts.bodyTransform) {
322
+ const out = this.opts.bodyTransform(body)
323
+ if (out) return out
324
+ }
325
+ return body
326
+ }
327
+
328
+ /** §8 Gap 5. OpenAI-style body; omits tools/tool_choice when the tool list is empty. Key order
329
+ * matches the pre-change body so a no-options call is byte-identical. */
330
+ private openaiBody(messages: any[], tools: any[], stream: boolean): Record<string, any> {
331
+ const b: Record<string, any> = { model: this.opts.model, messages }
332
+ if (tools.length) {
333
+ b.tools = tools
334
+ b.tool_choice = "auto"
335
+ }
336
+ if (stream) {
337
+ b.stream = true
338
+ b.stream_options = { include_usage: true }
339
+ }
340
+ return this.finalizeBody(b)
341
+ }
342
+
343
+ /** §8 Gap 5. Anthropic-style body; omits tools when the tool list is empty. */
344
+ private anthropicBody(system: string, messages: any[], tools: any[], stream: boolean): Record<string, any> {
345
+ const b: Record<string, any> = { model: this.opts.model, max_tokens: 4096, system, messages }
346
+ if (tools.length) b.tools = tools
347
+ if (stream) b.stream = true
348
+ return this.finalizeBody(b)
349
+ }
350
+
284
351
  /** Prometheus text exposition of cumulative metrics (§8). Always valid, empty-but-valid before activity. */
285
352
  metrics(): string {
286
353
  return this.registry.render()
@@ -379,7 +446,7 @@ export class Client {
379
446
  let lastErr: unknown
380
447
  for (let attempt = 0; attempt <= retries; attempt++) {
381
448
  try {
382
- const res = await fetch(url, { ...init, signal })
449
+ const res = await (this.opts.fetch ?? fetch)(url, { ...init, signal })
383
450
  if (res.ok || !RETRYABLE.has(res.status) || attempt === retries) return res
384
451
  const ra = Number(res.headers.get("retry-after"))
385
452
  await delay(ra ? ra * 1000 : base * 2 ** attempt + Math.random() * 100, signal)
@@ -470,7 +537,7 @@ export class Client {
470
537
  const data: any = await this.llmCallJson(`${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`, {
471
538
  method: "POST",
472
539
  headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", ...this.opts.headers },
473
- body: JSON.stringify({ model: this.opts.model, messages, tools, tool_choice: "auto" }),
540
+ body: JSON.stringify(this.openaiBody(messages, tools, false)),
474
541
  }, signal, "openai")
475
542
  addUsage(usage, data.usage, "openai")
476
543
  if (this.opts.hooks?.afterLLM) await this.opts.hooks.afterLLM({ response: data, model: this.opts.model, turn })
@@ -570,7 +637,7 @@ export class Client {
570
637
  "Content-Type": "application/json",
571
638
  ...this.opts.headers,
572
639
  },
573
- body: JSON.stringify({ model: this.opts.model, max_tokens: 4096, system, messages, tools }),
640
+ body: JSON.stringify(this.anthropicBody(system, messages, tools, false)),
574
641
  }, signal, "anthropic")
575
642
  addUsage(usage, data.usage, "anthropic")
576
643
  if (this.opts.hooks?.afterLLM) await this.opts.hooks.afterLLM({ response: data, model: this.opts.model, turn })
@@ -646,7 +713,7 @@ export class Client {
646
713
  const res = await this.llmFetch(`${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`, {
647
714
  method: "POST",
648
715
  headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", ...this.opts.headers },
649
- body: JSON.stringify({ model: this.opts.model, messages, tools, tool_choice: "auto", stream: true, stream_options: { include_usage: true } }),
716
+ body: JSON.stringify(this.openaiBody(messages, tools, true)),
650
717
  }, signal)
651
718
  if (!res.ok || !res.body) throw new Error(`LLM ${res.status}: ${await res.text()}`)
652
719
  for await (const line of sseLines(res.body)) {
@@ -740,7 +807,7 @@ export class Client {
740
807
  const res = await this.llmFetch(endpoint, {
741
808
  method: "POST",
742
809
  headers: { "x-api-key": key, "anthropic-version": "2023-06-01", "Content-Type": "application/json", ...this.opts.headers },
743
- body: JSON.stringify({ model: this.opts.model, max_tokens: 4096, system, messages, tools, stream: true }),
810
+ body: JSON.stringify(this.anthropicBody(system, messages, tools, true)),
744
811
  }, signal)
745
812
  if (!res.ok || !res.body) throw new Error(`LLM ${res.status}: ${await res.text()}`)
746
813
  for await (const line of sseLines(res.body)) {
package/src/mcp.ts CHANGED
@@ -51,6 +51,10 @@ export interface LocalServer {
51
51
  enabled?: boolean
52
52
  disabled?: boolean
53
53
  timeout?: number
54
+ /** §2 Gap 7. Per-server tool allowlist keyed on ORIGINAL tool name; same semantics as the
55
+ * builtins/skills filter (nil/empty ⇒ all; ≥1 true ⇒ allowlist; only-false ⇒ drop-list; unknown
56
+ * ⇒ ignore+warn). Applied to the listed defs before sanitize/prefix. */
57
+ tools?: Record<string, boolean>
54
58
  }
55
59
 
56
60
  export interface RemoteServer {
@@ -60,6 +64,8 @@ export interface RemoteServer {
60
64
  enabled?: boolean
61
65
  disabled?: boolean
62
66
  timeout?: number
67
+ /** §2 Gap 7. Per-server tool allowlist (see LocalServer.tools). */
68
+ tools?: Record<string, boolean>
63
69
  }
64
70
 
65
71
  export type ServerConfig = LocalServer | RemoteServer
@@ -128,6 +134,40 @@ function joinTextContent(content: unknown): string {
128
134
  .join("\n")
129
135
  }
130
136
 
137
+ /**
138
+ * §2 Gap 7. Apply a per-server tool allowlist to the listed defs. Same semantics as the builtins
139
+ * and skills filters: nil/empty ⇒ all; ≥1 true ⇒ allowlist (only true names); only-false ⇒ drop-list
140
+ * over all-on; unknown names ignored + warned once. Keyed on ORIGINAL tool name.
141
+ */
142
+ function applyToolsFilter(server: string, defs: McpToolDef[], filter?: Record<string, boolean>): McpToolDef[] {
143
+ if (!filter) return defs
144
+ const keys = Object.keys(filter)
145
+ if (keys.length === 0) return defs
146
+ const hasTrue = keys.some((k) => filter[k] === true)
147
+ const present = new Set(defs.map((d) => d.name))
148
+ for (const k of keys) {
149
+ if (!present.has(k)) console.warn(`[toolnexus] server "${server}" tools filter name "${k}" matched no tool`)
150
+ }
151
+ return defs.filter((d) => (hasTrue ? filter[d.name] === true : filter[d.name] !== false))
152
+ }
153
+
154
+ /**
155
+ * §2 Gap 3. Race a promise against a per-server timeout and the caller's abort signal, so a hung
156
+ * connect/list is bounded (the SSE fallback included) and a parent cancellation aborts promptly.
157
+ */
158
+ function raceTimeout<T>(p: Promise<T>, ms: number, signal?: AbortSignal): Promise<T> {
159
+ return new Promise<T>((resolve, reject) => {
160
+ const onAbort = () => { cleanup(); reject((signal as any)?.reason ?? new Error("aborted")) }
161
+ const t = setTimeout(() => { cleanup(); reject(new Error("mcp connect/list timeout")) }, ms)
162
+ const cleanup = () => { clearTimeout(t); signal?.removeEventListener("abort", onAbort) }
163
+ if (signal) {
164
+ if (signal.aborted) return onAbort()
165
+ signal.addEventListener("abort", onAbort, { once: true })
166
+ }
167
+ p.then((v) => { cleanup(); resolve(v) }, (e) => { cleanup(); reject(e) })
168
+ })
169
+ }
170
+
131
171
  async function paginateTools(client: Client, timeout: number): Promise<McpToolDef[]> {
132
172
  const result: McpToolDef[] = []
133
173
  const cursors = new Set<string>()
@@ -182,13 +222,59 @@ export interface McpSource {
182
222
  close(): Promise<void>
183
223
  }
184
224
 
185
- /** Connect to every enabled server, list + convert tools. Failures are isolated. */
186
- export async function loadMcp(input: string | object, opts?: { waitFor?: (request: Request) => Promise<Answer> }): Promise<McpSource> {
225
+ /** Create a fresh (not-yet-connected) client, with the elicitation bridge attached if a waitFor
226
+ * exists. Separated from connect so the caller ALWAYS holds a closeable reference — even if the
227
+ * connect is later abandoned by a timeout/abort, `client.close()` still terminates any spawned
228
+ * stdio child (no orphaned processes on cancellation). */
229
+ function newMcpClient(waitFor?: (request: Request) => Promise<Answer>): Client {
230
+ const client = new Client({ name: "toolnexus", version: "0.1.0" }, { capabilities: waitFor ? { elicitation: {} } : {} })
231
+ if (waitFor) {
232
+ client.setRequestHandler(ElicitRequestSchema, async (request) =>
233
+ answerToElicitResult(await waitFor(elicitationToRequest(request.params))),
234
+ )
235
+ }
236
+ return client
237
+ }
238
+
239
+ /** Connect an existing client's transport (remote streamable-HTTP with SSE fallback, or local stdio). */
240
+ async function connectTransport(client: Client, cfg: ServerConfig): Promise<void> {
241
+ if (isRemote(cfg)) {
242
+ const url = new URL(cfg.url)
243
+ const expanded = expandEnvHeaders(cfg.headers)
244
+ const requestInit = expanded ? { headers: expanded } : undefined
245
+ try {
246
+ await client.connect(new StreamableHTTPClientTransport(url, { requestInit }))
247
+ } catch {
248
+ await client.connect(new SSEClientTransport(url, { requestInit }))
249
+ }
250
+ } else {
251
+ const local = cfg as LocalServer
252
+ const [cmd, ...args] = local.command
253
+ await client.connect(
254
+ new StdioClientTransport({
255
+ command: cmd,
256
+ args,
257
+ cwd: local.cwd,
258
+ env: { ...(process.env as Record<string, string>), ...(local.environment ?? local.env ?? {}) },
259
+ stderr: "pipe",
260
+ }),
261
+ )
262
+ }
263
+ }
264
+
265
+ /** Connect to every enabled server, list + convert tools. Failures are isolated.
266
+ * §2 Gap 3: an optional `signal` bounds each connect/list by the server timeout and aborts the
267
+ * whole load promptly on parent cancellation. */
268
+ export async function loadMcp(
269
+ input: string | object,
270
+ opts?: { waitFor?: (request: Request) => Promise<Answer>; signal?: AbortSignal },
271
+ ): Promise<McpSource> {
187
272
  const config = parseMcpConfig(input)
188
273
  const tools: Tool[] = []
189
274
  const status: Record<string, McpStatus> = {}
190
275
  const clients: Client[] = []
191
276
  const waitFor = opts?.waitFor
277
+ const signal = opts?.signal
192
278
 
193
279
  await Promise.all(
194
280
  Object.entries(config).map(async ([name, cfg]) => {
@@ -197,45 +283,22 @@ export async function loadMcp(input: string | object, opts?: { waitFor?: (reques
197
283
  return
198
284
  }
199
285
  const timeout = cfg.timeout ?? DEFAULT_TIMEOUT
200
- // Advertise elicitation + register the bridge ONLY when a waitFor exists to satisfy it —
201
- // a waitFor-less host degrades cleanly (the server won't elicit). (§10 elicitation bridge)
202
- const client = new Client({ name: "toolnexus", version: "0.1.0" }, { capabilities: waitFor ? { elicitation: {} } : {} })
203
- if (waitFor) {
204
- client.setRequestHandler(ElicitRequestSchema, async (request) =>
205
- answerToElicitResult(await waitFor(elicitationToRequest(request.params))),
206
- )
207
- }
286
+ // Create the client up front so a timed-out/aborted connect still has a closeable handle
287
+ // (its stdio child is killed by close() — never orphaned).
288
+ const client = newMcpClient(waitFor)
208
289
  try {
209
- if (isRemote(cfg)) {
210
- const url = new URL(cfg.url)
211
- const expanded = expandEnvHeaders(cfg.headers)
212
- const requestInit = expanded ? { headers: expanded } : undefined
213
- try {
214
- await client.connect(new StreamableHTTPClientTransport(url, { requestInit }))
215
- } catch {
216
- await client.connect(new SSEClientTransport(url, { requestInit }))
217
- }
218
- } else {
219
- const local = cfg as LocalServer
220
- const [cmd, ...args] = local.command
221
- await client.connect(
222
- new StdioClientTransport({
223
- command: cmd,
224
- args,
225
- cwd: local.cwd,
226
- env: { ...(process.env as Record<string, string>), ...(local.environment ?? local.env ?? {}) },
227
- stderr: "pipe",
228
- }),
229
- )
230
- }
231
- const defs = await paginateTools(client, timeout)
232
- for (const def of defs) tools.push(convertTool(name, def, client, timeout))
290
+ await raceTimeout(connectTransport(client, cfg), timeout, signal)
291
+ const defs = await raceTimeout(paginateTools(client, timeout), timeout, signal)
292
+ const kept = applyToolsFilter(name, defs, (cfg as { tools?: Record<string, boolean> }).tools)
293
+ for (const def of kept) tools.push(convertTool(name, def, client, timeout))
233
294
  clients.push(client)
234
295
  status[name] = "connected"
235
296
  } catch (e) {
236
297
  status[name] = "failed"
237
298
  await client.close().catch(() => {})
238
- // one bad server never breaks the toolkit
299
+ // Parent cancellation/deadline aborts the whole load (§2 Gap 3, A1); a per-server timeout
300
+ // within budget isolates just this server.
301
+ if (signal?.aborted) throw e
239
302
  console.warn(`[toolnexus] MCP server "${name}" failed: ${e instanceof Error ? e.message : String(e)}`)
240
303
  }
241
304
  }),
@@ -249,3 +312,62 @@ export async function loadMcp(input: string | object, opts?: { waitFor?: (reques
249
312
  },
250
313
  }
251
314
  }
315
+
316
+ /** §2 Gap 6. One listed tool definition (original, unprefixed name). */
317
+ export interface ToolInfo {
318
+ name: string
319
+ description: string
320
+ inputSchema: JSONSchema
321
+ }
322
+
323
+ /** §2 Gap 6. Result of a list-only pass over an MCP config. */
324
+ export interface McpInventory {
325
+ /** server → its full listed tool defs, UNFILTERED by ServerConfig.tools (authors the allowlist). */
326
+ tools: Record<string, ToolInfo[]>
327
+ status: Record<string, McpStatus>
328
+ }
329
+
330
+ /**
331
+ * §2 Gap 6. Connect to every enabled server, list tool definitions, and DISCONNECT before
332
+ * returning — no toolkit, no execute wiring, nothing left running. Failure isolation as in loadMcp.
333
+ */
334
+ export async function listMcpTools(input: string | object, opts?: { signal?: AbortSignal }): Promise<McpInventory> {
335
+ const config = parseMcpConfig(input)
336
+ const toolsByServer: Record<string, ToolInfo[]> = {}
337
+ const status: Record<string, McpStatus> = {}
338
+ const signal = opts?.signal
339
+
340
+ await Promise.all(
341
+ Object.entries(config).map(async ([name, cfg]) => {
342
+ if (!isEnabled(cfg)) {
343
+ status[name] = "disabled"
344
+ return
345
+ }
346
+ const timeout = cfg.timeout ?? DEFAULT_TIMEOUT
347
+ // Client created up front so an aborted/timed-out connect is still closed (no orphaned child).
348
+ const client = newMcpClient()
349
+ try {
350
+ await raceTimeout(connectTransport(client, cfg), timeout, signal)
351
+ const defs = await raceTimeout(paginateTools(client, timeout), timeout, signal)
352
+ toolsByServer[name] = defs.map((d) => ({
353
+ name: d.name,
354
+ description: d.description ?? "",
355
+ inputSchema: {
356
+ ...(d.inputSchema as object),
357
+ type: "object",
358
+ properties: (d.inputSchema?.properties ?? {}) as Record<string, unknown>,
359
+ } as JSONSchema,
360
+ }))
361
+ status[name] = "connected"
362
+ } catch (e) {
363
+ status[name] = "failed"
364
+ if (signal?.aborted) { await client.close().catch(() => {}); throw e }
365
+ console.warn(`[toolnexus] MCP server "${name}" failed: ${e instanceof Error ? e.message : String(e)}`)
366
+ } finally {
367
+ await client.close().catch(() => {})
368
+ }
369
+ }),
370
+ )
371
+
372
+ return { tools: toolsByServer, status }
373
+ }
package/src/toolkit.ts CHANGED
@@ -35,6 +35,9 @@ export interface ToolkitOptions {
35
35
  * elicitation is not advertised.
36
36
  */
37
37
  waitFor?: (request: import("./types.js").Request) => Promise<import("./types.js").Answer>
38
+ /** §2 Gap 3. Bounds the MCP load: each connect/list is capped by the server timeout and the
39
+ * whole load aborts promptly if this signal fires. Omit ⇒ unbounded as before. */
40
+ signal?: AbortSignal
38
41
  }
39
42
 
40
43
  export class Toolkit {
@@ -66,7 +69,7 @@ export class Toolkit {
66
69
  }
67
70
 
68
71
  static async create(opts: ToolkitOptions): Promise<Toolkit> {
69
- const mcp = opts.mcpConfig !== undefined ? await loadMcp(opts.mcpConfig, { waitFor: opts.waitFor }) : undefined
72
+ const mcp = opts.mcpConfig !== undefined ? await loadMcp(opts.mcpConfig, { waitFor: opts.waitFor, signal: opts.signal }) : undefined
70
73
  // Skills come from directories, in-memory data, and/or a lazy provider (§3,
71
74
  // S1). The provider is resolved here (create() is async) and merged with the
72
75
  // data list; a provider failure is isolated so other sources still load.