toolnexus 0.26.0 → 0.28.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/index.d.ts CHANGED
@@ -8,6 +8,7 @@ export * from "./native.js";
8
8
  export * from "./http.js";
9
9
  export * from "./a2a.js";
10
10
  export * from "./acp.js";
11
+ export * from "./transcript_search.js";
11
12
  export * from "./serve.js";
12
13
  export * from "./mcpserve.js";
13
14
  export * from "./mcphandler.js";
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@ export * from "./native.js";
8
8
  export * from "./http.js";
9
9
  export * from "./a2a.js";
10
10
  export * from "./acp.js";
11
+ export * from "./transcript_search.js";
11
12
  export * from "./serve.js";
12
13
  export * from "./mcpserve.js";
13
14
  export * from "./mcphandler.js";
package/dist/mcp.js CHANGED
@@ -6,14 +6,17 @@
6
6
  import { readFileSync } from "node:fs";
7
7
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
8
8
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
9
- import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
10
- import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
9
+ import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv";
10
+ import { AdoptedStdioTransport } from "./mcp2026/stdio.js";
11
11
  import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
12
12
  import { sanitize } from "./types.js";
13
13
  import { summarizePart } from "./content.js";
14
- import { probe, newOAuth, defaultTokenStore } from "./mcp2026/source.js";
15
14
  import { PendingError } from "./mcp2026/auth.js";
16
15
  export { Token, MemoryTokenStore } from "./mcp2026/auth.js";
16
+ // Loaded on first use, not at import: the 2026 client is only needed once a config is loaded,
17
+ // and the SDK's remote transports (streamable-HTTP, SSE) only when a remote server falls back.
18
+ let mcp2026;
19
+ const loadMcp2026 = () => (mcp2026 ??= import("./mcp2026/source.js"));
17
20
  // ---------------------------------------------------------------------------
18
21
  // MCP elicitation bridge (§10). A server can ask US for input mid-`tools/call`
19
22
  // (a reverse-request). We map it onto the one `waitFor`: form mode → kind:"input",
@@ -396,8 +399,19 @@ function resourceTools(servers) {
396
399
  * exists. Separated from connect so the caller ALWAYS holds a closeable reference — even if the
397
400
  * connect is later abandoned by a timeout/abort, `client.close()` still terminates any spawned
398
401
  * stdio child (no orphaned processes on cancellation). */
402
+ /**
403
+ * The SDK's default validator, built on first use instead of per Client. Building an Ajv instance
404
+ * (meta-schemas + formats) costs ~2-3 ms on every connect, yet it is only consulted when a tool
405
+ * declares an `outputSchema`. Same validator, same results — just not paid for up front.
406
+ */
407
+ class LazyAjvValidator {
408
+ inner;
409
+ getValidator(schema) {
410
+ return (this.inner ??= new AjvJsonSchemaValidator()).getValidator(schema);
411
+ }
412
+ }
399
413
  function newMcpClient(waitFor) {
400
- const client = new Client({ name: "toolnexus", version: "0.1.0" }, { capabilities: waitFor ? { elicitation: {} } : {} });
414
+ const client = new Client({ name: "toolnexus", version: "0.1.0" }, { capabilities: waitFor ? { elicitation: {} } : {}, jsonSchemaValidator: new LazyAjvValidator() });
401
415
  if (waitFor) {
402
416
  client.setRequestHandler(ElicitRequestSchema, async (request) => answerToElicitResult(await waitFor(elicitationToRequest(request.params))));
403
417
  }
@@ -410,6 +424,10 @@ async function connectTransport(client, cfg, auth) {
410
424
  const expanded = expandEnvHeaders(cfg.headers);
411
425
  const requestInit = expanded ? { headers: expanded } : undefined;
412
426
  const fetch = auth ? bearerFetch(cfg.url, auth) : undefined;
427
+ const [{ StreamableHTTPClientTransport }, { SSEClientTransport }] = await Promise.all([
428
+ import("@modelcontextprotocol/sdk/client/streamableHttp.js"),
429
+ import("@modelcontextprotocol/sdk/client/sse.js"),
430
+ ]);
413
431
  try {
414
432
  await client.connect(new StreamableHTTPClientTransport(url, { requestInit, ...(fetch ? { fetch } : {}) }));
415
433
  }
@@ -469,6 +487,7 @@ function bearerFetch(resource, a) {
469
487
  * path, with the OAuth bearer attached for remote servers. Throws ⇒ the server failed.
470
488
  */
471
489
  async function connectServer(name, cfg, timeout, opts, store, sdkClient, onSdk) {
490
+ const { newOAuth, probe } = await loadMcp2026();
472
491
  const o = { waitFor: opts.waitFor, tokenStore: store, openUrl: opts.openUrl, signal: opts.signal, disableTools: opts.disableTools };
473
492
  const remote = isRemote(cfg);
474
493
  const oauth = remote ? newOAuth(cfg.auth, o, store) : undefined;
@@ -485,7 +504,10 @@ async function connectServer(name, cfg, timeout, opts, store, sdkClient, onSdk)
485
504
  return p;
486
505
  const client = sdkClient();
487
506
  onSdk(client);
488
- await raceTimeout(connectTransport(client, cfg, oauth ? { oauth, waitFor: opts.waitFor } : undefined), timeout, opts.signal);
507
+ const connecting = p.reuse
508
+ ? client.connect(new AdoptedStdioTransport(p.reuse.child, p.reuse.leftover)) // §2.2a: same child, no respawn
509
+ : connectTransport(client, cfg, oauth ? { oauth, waitFor: opts.waitFor } : undefined);
510
+ await raceTimeout(connecting, timeout, opts.signal);
489
511
  const defs = await raceTimeout(paginateTools(client, timeout), timeout, opts.signal);
490
512
  return { kind: "sdk", client, defs };
491
513
  }
@@ -499,7 +521,7 @@ export async function loadMcp(input, opts) {
499
521
  const clients = [];
500
522
  const servers = new Map();
501
523
  const skills = [];
502
- const store = defaultTokenStore({ tokenStore: opts?.tokenStore });
524
+ const store = (await loadMcp2026()).defaultTokenStore({ tokenStore: opts?.tokenStore });
503
525
  const waitFor = opts?.waitFor;
504
526
  const signal = opts?.signal;
505
527
  const onServer = opts?.onServer;
@@ -590,7 +612,7 @@ export async function listMcpTools(input, opts) {
590
612
  const toolsByServer = {};
591
613
  const status = {};
592
614
  const signal = opts?.signal;
593
- const store = defaultTokenStore({ tokenStore: opts?.tokenStore });
615
+ const store = (await loadMcp2026()).defaultTokenStore({ tokenStore: opts?.tokenStore });
594
616
  await Promise.all(Object.entries(config).map(async ([name, cfg]) => {
595
617
  if (!isEnabled(cfg)) {
596
618
  status[name] = "disabled";
@@ -1,6 +1,7 @@
1
1
  import { Mcp2026Client } from "./client.js";
2
2
  import { type Obj } from "./protocol.js";
3
3
  import { OAuth, type TokenStore } from "./auth.js";
4
+ import type { ChildProcess } from "node:child_process";
4
5
  import type { Answer, Request, Tool, ToolContext, ToolResult } from "../types.js";
5
6
  import type { SkillDef } from "../skill.js";
6
7
  export type WaitFor = (request: Request) => Promise<Answer>;
@@ -33,6 +34,10 @@ export type Probe = {
33
34
  server: ModernServer;
34
35
  } | {
35
36
  kind: "fallback";
37
+ reuse?: {
38
+ child: ChildProcess;
39
+ leftover: Buffer;
40
+ };
36
41
  };
37
42
  export interface ProbeTarget {
38
43
  name: string;
@@ -78,12 +78,15 @@ function supports2026(r) {
78
78
  /** Discover first (§2.2). Resolves `modern` (tools listed) or `fallback`; throws on a load failure. */
79
79
  export async function probe(t, o) {
80
80
  let client;
81
+ let stdio;
81
82
  if (t.remote) {
82
83
  client = new Mcp2026Client(t.remote.url, { headers: t.remote.headers, auth: t.remote.oauth, timeoutMs: t.timeout });
83
84
  }
84
85
  else {
85
- const tr = await spawnStdio(t.local.argv, t.local.env, t.local.cwd);
86
- client = new Mcp2026Client("", { transport: tr, timeoutMs: t.timeout });
86
+ stdio = await spawnStdio(t.local.argv, t.local.env, t.local.cwd);
87
+ // Stop parsing right after the discover reply so an old server's child can be handed over.
88
+ stdio.holdAfterNextResponse();
89
+ client = new Mcp2026Client("", { transport: stdio, timeoutMs: t.timeout });
87
90
  }
88
91
  const oauth = t.remote?.oauth;
89
92
  let disc;
@@ -94,6 +97,9 @@ export async function probe(t, o) {
94
97
  }
95
98
  catch (e) {
96
99
  const stdioTimeout = !!t.local && e instanceof Error && e.message === "stdio request timed out";
100
+ // §2.2a: a fast JSON-RPC error over stdio ⇒ the SAME child continues into initialize.
101
+ if (stdio && e instanceof RpcError && !o.signal?.aborted)
102
+ return { kind: "fallback", reuse: stdio.detach() };
97
103
  await client.close().catch(() => { });
98
104
  if (o.signal?.aborted)
99
105
  throw e;
@@ -104,9 +110,12 @@ export async function probe(t, o) {
104
110
  throw e;
105
111
  }
106
112
  if (!supports2026(disc)) {
113
+ if (stdio)
114
+ return { kind: "fallback", reuse: stdio.detach() };
107
115
  await client.close().catch(() => { });
108
116
  return { kind: "fallback" };
109
117
  }
118
+ stdio?.release();
110
119
  try {
111
120
  const server = new ModernServer(t, client, disc, o);
112
121
  await server.load();
@@ -6,6 +6,8 @@
6
6
  import { type ChildProcess } from "node:child_process";
7
7
  import type { Transport } from "./client.js";
8
8
  import type { Obj } from "./protocol.js";
9
+ import type { Transport as SdkTransport } from "@modelcontextprotocol/sdk/shared/transport.js";
10
+ import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js";
9
11
  /** The child exited / closed stdout before answering. */
10
12
  export declare class StdioClosedError extends Error {
11
13
  }
@@ -14,7 +16,28 @@ export declare class StdioTransport implements Transport {
14
16
  private err?;
15
17
  private done;
16
18
  private child;
19
+ private buf;
20
+ private onData;
21
+ private onEnd;
22
+ private detached;
23
+ private hold;
24
+ private held;
17
25
  constructor(child: ChildProcess);
26
+ private drain;
27
+ /** Probe mode: after the next delivered response, stop parsing (bytes stay buffered for detach). */
28
+ holdAfterNextResponse(): void;
29
+ /** Leave probe mode and parse whatever was held. */
30
+ release(): void;
31
+ private onLine;
32
+ /**
33
+ * §2.2a handoff: stop reading stdout (listeners removed, stream paused — no read left pending)
34
+ * and give the live child plus any bytes already read past the last delivered line to the
35
+ * frozen path. The transport is unusable afterwards; the child is NOT killed.
36
+ */
37
+ detach(): {
38
+ child: ChildProcess;
39
+ leftover: Buffer;
40
+ };
18
41
  /** Writes one line and waits for the response with the same id. Never retried. */
19
42
  roundTrip(id: number, body: string, timeoutMs: number, signal?: AbortSignal): Promise<Obj>;
20
43
  /** Close stdin, then SIGKILL after 2 s if the child is still alive. */
@@ -22,3 +45,23 @@ export declare class StdioTransport implements Transport {
22
45
  }
23
46
  /** Spawns argv (no shell) and returns its transport; rejects if the spawn itself fails. */
24
47
  export declare function spawnStdio(argv: string[], env: NodeJS.ProcessEnv, cwd?: string): Promise<StdioTransport>;
48
+ /**
49
+ * §2.2a: the frozen SDK path over a child the probe already started (StdioClientTransport always
50
+ * spawns). Takes the detached probe's stdin/stdout plus any bytes it had read but not parsed.
51
+ */
52
+ export declare class AdoptedStdioTransport implements SdkTransport {
53
+ private child;
54
+ private leftover;
55
+ onclose?: () => void;
56
+ onerror?: (error: Error) => void;
57
+ onmessage?: (message: JSONRPCMessage) => void;
58
+ private rb;
59
+ private closed;
60
+ constructor(child: ChildProcess, leftover: Buffer);
61
+ start(): Promise<void>;
62
+ private feed;
63
+ send(message: JSONRPCMessage): Promise<void>;
64
+ private finish;
65
+ /** Close stdin, then SIGKILL after 2 s if the child is still alive. */
66
+ close(): Promise<void>;
67
+ }
@@ -4,7 +4,7 @@
4
4
  * pending call instead of hanging. No headers, no auth.
5
5
  */
6
6
  import { spawn } from "node:child_process";
7
- import { createInterface } from "node:readline";
7
+ import { ReadBuffer, serializeMessage } from "@modelcontextprotocol/sdk/shared/stdio.js";
8
8
  /** The child exited / closed stdout before answering. */
9
9
  export class StdioClosedError extends Error {
10
10
  }
@@ -13,33 +13,86 @@ export class StdioTransport {
13
13
  err;
14
14
  done;
15
15
  child;
16
+ buf = Buffer.alloc(0);
17
+ onData;
18
+ onEnd;
19
+ detached = false;
20
+ hold = false;
21
+ held = false;
16
22
  constructor(child) {
17
23
  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", () => {
24
+ // Line-exact own reader (no readline): detach() can stop it and hand over unread bytes.
25
+ let closed;
26
+ this.done = new Promise((r) => (closed = r));
27
+ this.onData = (chunk) => {
28
+ this.buf = this.buf.length ? Buffer.concat([this.buf, chunk]) : chunk;
29
+ this.drain();
30
+ };
31
+ this.onEnd = () => {
35
32
  this.err ??= new StdioClosedError("stdio server closed its stdout");
36
33
  for (const f of this.pending.values())
37
34
  f({ __closed: true });
38
35
  this.pending.clear();
39
- r();
40
- }));
36
+ closed();
37
+ };
38
+ const out = child.stdout;
39
+ out.on("data", this.onData);
40
+ out.once("end", this.onEnd);
41
+ out.once("close", this.onEnd);
41
42
  child.stdin?.on("error", () => { }); // EPIPE after the server died surfaces as a closed stdout
42
43
  }
44
+ drain() {
45
+ let nl;
46
+ while (!this.detached && !this.held && (nl = this.buf.indexOf(10)) >= 0) {
47
+ const line = this.buf.subarray(0, nl).toString("utf8").replace(/\r$/, "");
48
+ this.buf = this.buf.subarray(nl + 1);
49
+ this.onLine(line);
50
+ }
51
+ }
52
+ /** Probe mode: after the next delivered response, stop parsing (bytes stay buffered for detach). */
53
+ holdAfterNextResponse() {
54
+ this.hold = true;
55
+ }
56
+ /** Leave probe mode and parse whatever was held. */
57
+ release() {
58
+ this.hold = false;
59
+ this.held = false;
60
+ this.drain();
61
+ }
62
+ onLine(line) {
63
+ let m;
64
+ try {
65
+ m = JSON.parse(line);
66
+ }
67
+ catch {
68
+ return; // noise
69
+ }
70
+ if (!m || typeof m !== "object" || m.method !== undefined || m.id === undefined || m.id === null)
71
+ return; // not a response
72
+ const key = JSON.stringify(m.id);
73
+ const f = this.pending.get(key);
74
+ this.pending.delete(key);
75
+ if (f && this.hold)
76
+ this.held = true;
77
+ f?.(m);
78
+ }
79
+ /**
80
+ * §2.2a handoff: stop reading stdout (listeners removed, stream paused — no read left pending)
81
+ * and give the live child plus any bytes already read past the last delivered line to the
82
+ * frozen path. The transport is unusable afterwards; the child is NOT killed.
83
+ */
84
+ detach() {
85
+ this.detached = true;
86
+ const out = this.child.stdout;
87
+ out.off("data", this.onData);
88
+ out.off("end", this.onEnd);
89
+ out.off("close", this.onEnd);
90
+ out.pause();
91
+ this.err ??= new StdioClosedError("stdio transport detached");
92
+ const leftover = this.buf;
93
+ this.buf = Buffer.alloc(0);
94
+ return { child: this.child, leftover };
95
+ }
43
96
  /** Writes one line and waits for the response with the same id. Never retried. */
44
97
  roundTrip(id, body, timeoutMs, signal) {
45
98
  if (this.err)
@@ -97,3 +150,74 @@ export function spawnStdio(argv, env, cwd) {
97
150
  child.once("spawn", () => resolve(new StdioTransport(child)));
98
151
  });
99
152
  }
153
+ /**
154
+ * §2.2a: the frozen SDK path over a child the probe already started (StdioClientTransport always
155
+ * spawns). Takes the detached probe's stdin/stdout plus any bytes it had read but not parsed.
156
+ */
157
+ export class AdoptedStdioTransport {
158
+ child;
159
+ leftover;
160
+ onclose;
161
+ onerror;
162
+ onmessage;
163
+ rb = new ReadBuffer();
164
+ closed = false;
165
+ constructor(child, leftover) {
166
+ this.child = child;
167
+ this.leftover = leftover;
168
+ }
169
+ async start() {
170
+ const out = this.child.stdout;
171
+ out.on("data", (chunk) => this.feed(chunk));
172
+ out.on("error", (e) => this.onerror?.(e));
173
+ this.child.stdin?.on("error", (e) => this.onerror?.(e));
174
+ this.child.once("close", () => this.finish());
175
+ if (this.leftover.length)
176
+ this.feed(this.leftover);
177
+ this.leftover = Buffer.alloc(0);
178
+ out.resume();
179
+ }
180
+ feed(chunk) {
181
+ this.rb.append(chunk);
182
+ for (;;) {
183
+ let m;
184
+ try {
185
+ m = this.rb.readMessage();
186
+ }
187
+ catch (e) {
188
+ this.onerror?.(e);
189
+ continue;
190
+ }
191
+ if (m === null)
192
+ break;
193
+ this.onmessage?.(m);
194
+ }
195
+ }
196
+ send(message) {
197
+ return new Promise((resolve) => {
198
+ if (!this.child.stdin?.write(serializeMessage(message)))
199
+ this.child.stdin?.once("drain", resolve);
200
+ else
201
+ resolve();
202
+ });
203
+ }
204
+ finish() {
205
+ if (this.closed)
206
+ return;
207
+ this.closed = true;
208
+ this.rb.clear();
209
+ this.onclose?.();
210
+ }
211
+ /** Close stdin, then SIGKILL after 2 s if the child is still alive. */
212
+ async close() {
213
+ if (this.child.exitCode === null && this.child.signalCode === null) {
214
+ const exited = new Promise((r) => this.child.once("exit", () => r()));
215
+ this.child.stdin?.end();
216
+ const t = setTimeout(() => this.child.kill("SIGKILL"), 2000);
217
+ await exited;
218
+ clearTimeout(t);
219
+ }
220
+ this.child.stdout?.destroy();
221
+ this.finish();
222
+ }
223
+ }
@@ -196,7 +196,7 @@ export function createMcpHandler(toolkitTools, cfg = {}) {
196
196
  /** Validates protocol header + session (stateful). Writes the HTTP error itself on failure. */
197
197
  async function checkSession(ctx, req, res) {
198
198
  const v = header(req, "MCP-Protocol-Version");
199
- if (v !== "" && !legacy.includes(v)) {
199
+ if (v !== "" && !versions.includes(v)) {
200
200
  httpError(res, 400, "Bad Request: unsupported MCP-Protocol-Version");
201
201
  return false;
202
202
  }
@@ -655,9 +655,7 @@ export function createMcpHandler(toolkitTools, cfg = {}) {
655
655
  if (method === "initialize")
656
656
  return false;
657
657
  const hv = header(req, "MCP-Protocol-Version");
658
- if (hv === MCP_PROTOCOL_2026)
659
- return true;
660
- if (hv !== "" && versions.includes(hv))
658
+ if (hv !== "" && legacy.includes(hv))
661
659
  return false;
662
660
  return method === "server/discover" || header(req, "Mcp-Method") !== "" || (meta !== undefined && META_VERSION in meta);
663
661
  }
package/dist/mcpserve.js CHANGED
@@ -15,7 +15,6 @@
15
15
  import http from "node:http";
16
16
  import { randomBytes, timingSafeEqual } from "node:crypto";
17
17
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
18
- import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
19
18
  import { ListToolsRequestSchema, CallToolRequestSchema, McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
20
19
  const DEFAULT_NAME = "toolnexus";
21
20
  const DEFAULT_VERSION = "0.1.0";
@@ -133,6 +132,8 @@ function partBlocks(parts) {
133
132
  */
134
133
  export async function serveLocalMcp(tools, cfg) {
135
134
  const exposed = exposedMcpTools(tools, cfg);
135
+ // Loaded on first serve, not at import: the transport pulls in @hono/node-server.
136
+ const { StreamableHTTPServerTransport } = await import("@modelcontextprotocol/sdk/server/streamableHttp.js");
136
137
  const token = randomBytes(32).toString("hex");
137
138
  const want = Buffer.from("Bearer " + token);
138
139
  const server = http.createServer((req, res) => {
@@ -0,0 +1 @@
1
+ export declare const nodeFetch: typeof fetch;
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Default LLM transport: a `fetch`-shaped function over `node:http`/`node:https` with a shared
3
+ * keep-alive agent.
4
+ *
5
+ * Why: Node's built-in `fetch` (undici + web streams) costs ~1.5 ms per local round-trip, against
6
+ * ~0.3 ms for `node:http` on the same keep-alive socket. With two LLM calls per tool-calling turn
7
+ * that was most of the client's per-request overhead (benchmarks/run_toolnexus_js.mjs).
8
+ *
9
+ * Behavior is kept identical to `fetch` for what the client uses (status, headers incl.
10
+ * Retry-After, json/text, a streamed `body` for SSE, abort via `signal` rejecting with its reason).
11
+ * Anything this path does not cover is handed to `globalThis.fetch` unchanged: a proxy env
12
+ * (`HTTP(S)_PROXY`, `NODE_USE_ENV_PROXY`), a replaced global `fetch`, a non-http(s) URL, a body
13
+ * that is not a string/bytes, and redirects.
14
+ */
15
+ import http from "node:http";
16
+ import https from "node:https";
17
+ import { Readable } from "node:stream";
18
+ import zlib from "node:zlib";
19
+ const nativeFetch = globalThis.fetch;
20
+ const httpAgent = new http.Agent({ keepAlive: true });
21
+ const httpsAgent = new https.Agent({ keepAlive: true });
22
+ function proxyEnv() {
23
+ const e = process.env;
24
+ return !!(e.HTTP_PROXY || e.HTTPS_PROXY || e.http_proxy || e.https_proxy || e.NODE_USE_ENV_PROXY);
25
+ }
26
+ function toHeaderRecord(h) {
27
+ const out = {};
28
+ if (!h)
29
+ return out;
30
+ if (h instanceof Headers || Array.isArray(h)) {
31
+ for (const [k, v] of h)
32
+ out[k] = v;
33
+ return out;
34
+ }
35
+ return { ...h };
36
+ }
37
+ // undici's global dispatcher slot. Node fills it lazily with a default Agent on the first fetch;
38
+ // anything else there (a ProxyAgent, a mock, a tuned Agent) means the host routed fetch on purpose.
39
+ const DISPATCHER = Symbol.for("undici.globalDispatcher.1");
40
+ let knownDefaultDispatcher;
41
+ function customDispatcher() {
42
+ const d = globalThis[DISPATCHER];
43
+ return d !== undefined && d !== knownDefaultDispatcher;
44
+ }
45
+ /** Calls the platform fetch; if that call is what created Node's default dispatcher, remember it
46
+ * so later calls still take the fast path. */
47
+ function platformFetch(input, init) {
48
+ const before = globalThis[DISPATCHER];
49
+ const p = globalThis.fetch(input, init);
50
+ if (before === undefined) {
51
+ const after = globalThis[DISPATCHER];
52
+ if (after !== undefined)
53
+ knownDefaultDispatcher = after;
54
+ }
55
+ return p;
56
+ }
57
+ const REDIRECTS = new Set([301, 302, 303, 307, 308]);
58
+ export const nodeFetch = (input, init) => {
59
+ const fallback = () => platformFetch(input, init);
60
+ if (globalThis.fetch !== nativeFetch || proxyEnv() || customDispatcher())
61
+ return fallback();
62
+ if (init?.redirect && init.redirect !== "follow")
63
+ return fallback();
64
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : undefined;
65
+ const body = init?.body;
66
+ if (url === undefined || (body != null && typeof body !== "string" && !(body instanceof Uint8Array)))
67
+ return fallback();
68
+ const isHttps = url.startsWith("https:");
69
+ if (!isHttps && !url.startsWith("http:"))
70
+ return fallback();
71
+ const signal = init?.signal ?? undefined;
72
+ if (signal?.aborted)
73
+ return Promise.reject(signal.reason);
74
+ return new Promise((resolve, reject) => {
75
+ const headers = toHeaderRecord(init?.headers);
76
+ if (body != null && !Object.keys(headers).some((k) => k.toLowerCase() === "content-length")) {
77
+ headers["content-length"] = String(typeof body === "string" ? Buffer.byteLength(body) : body.byteLength);
78
+ }
79
+ const mod = isHttps ? https : http;
80
+ let settled = false;
81
+ const onAbort = () => {
82
+ req.destroy(signal.reason);
83
+ if (!settled) {
84
+ settled = true;
85
+ reject(signal.reason);
86
+ }
87
+ };
88
+ const req = mod.request(url, { method: init?.method ?? "GET", headers, agent: isHttps ? httpsAgent : httpAgent }, (res) => {
89
+ const status = res.statusCode ?? 0;
90
+ if (REDIRECTS.has(status) && res.headers.location) {
91
+ // Follow it the way fetch does, from the redirect target: re-sending to the original URL
92
+ // would reach the server twice. 303, and 301/302 after a POST, become a bodiless GET;
93
+ // 307/308 repeat the method and body. Further hops are fetch's to follow.
94
+ res.resume();
95
+ signal?.removeEventListener("abort", onAbort);
96
+ settled = true;
97
+ const target = new URL(res.headers.location, url).href;
98
+ const method = (init?.method ?? "GET").toUpperCase();
99
+ const toGet = status === 303 ? method !== "HEAD" : (status === 301 || status === 302) && method === "POST";
100
+ let next = init;
101
+ if (toGet) {
102
+ const h = toHeaderRecord(init?.headers);
103
+ for (const k of Object.keys(h)) {
104
+ if (/^content-(type|length|encoding|language|location)$/i.test(k))
105
+ delete h[k];
106
+ }
107
+ next = { ...init, method: "GET", body: undefined, headers: h };
108
+ }
109
+ platformFetch(target, next).then(resolve, reject);
110
+ return;
111
+ }
112
+ const h = new Headers();
113
+ for (let i = 0; i < res.rawHeaders.length; i += 2)
114
+ h.append(res.rawHeaders[i], res.rawHeaders[i + 1]);
115
+ let stream = res;
116
+ const enc = (res.headers["content-encoding"] ?? "").toLowerCase();
117
+ if (enc === "gzip" || enc === "x-gzip" || enc === "deflate")
118
+ stream = res.pipe(zlib.createUnzip());
119
+ else if (enc === "br")
120
+ stream = res.pipe(zlib.createBrotliDecompress());
121
+ const nullBody = status === 204 || status === 304 || init?.method === "HEAD";
122
+ const ctype = (res.headers["content-type"] ?? "").toLowerCase();
123
+ const done = (b) => {
124
+ if (settled)
125
+ return;
126
+ settled = true;
127
+ resolve(new Response(nullBody ? null : b, { status, statusText: res.statusMessage, headers: h }));
128
+ };
129
+ if (ctype.includes("text/event-stream")) {
130
+ // Streamed: the body is consumed incrementally (SSE). Abort still destroys the socket.
131
+ stream.once("end", () => signal?.removeEventListener("abort", onAbort));
132
+ done(Readable.toWeb(stream));
133
+ return;
134
+ }
135
+ const chunks = [];
136
+ stream.on("data", (c) => chunks.push(c));
137
+ stream.once("error", (e) => { if (!settled) {
138
+ settled = true;
139
+ reject(e);
140
+ } });
141
+ stream.once("end", () => {
142
+ signal?.removeEventListener("abort", onAbort);
143
+ done(Buffer.concat(chunks));
144
+ });
145
+ });
146
+ req.once("error", (e) => {
147
+ signal?.removeEventListener("abort", onAbort);
148
+ if (!settled) {
149
+ settled = true;
150
+ reject(signal?.aborted ? signal.reason : new TypeError("fetch failed", { cause: e }));
151
+ }
152
+ });
153
+ signal?.addEventListener("abort", onAbort, { once: true });
154
+ req.end(body ?? undefined);
155
+ });
156
+ };
package/dist/serve.js CHANGED
@@ -18,7 +18,8 @@ import http from "node:http";
18
18
  import fs from "node:fs";
19
19
  import path from "node:path";
20
20
  import { randomUUID } from "node:crypto";
21
- import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
21
+ // The SDK server transport (and the @hono/node-server it pulls in) is loaded only when an MCP
22
+ // profile is actually served, so importing toolnexus does not pay for it.
22
23
  import { sanitize } from "./types.js";
23
24
  import { buildMcpServer, exposedMcpTools } from "./mcpserve.js";
24
25
  /** Default in-memory store — Tasks live only for the lifetime of the process. */
@@ -136,6 +137,9 @@ export async function startA2AServer(opts) {
136
137
  const [host, portStr] = splitAddr(opts.addr);
137
138
  const store = a2a ? resolveStore(a2a.store) : undefined;
138
139
  const exposedTools = mcp ? exposedMcpTools(mcpTools ?? [], mcp) : [];
140
+ const StreamableHTTPServerTransport = mcp
141
+ ? (await import("@modelcontextprotocol/sdk/server/streamableHttp.js")).StreamableHTTPServerTransport
142
+ : undefined;
139
143
  const server = http.createServer((req, res) => {
140
144
  const rpcRes = (id, payload) => {
141
145
  res.writeHead(200, { "content-type": "application/json" });
package/dist/skill.d.ts CHANGED
@@ -39,10 +39,22 @@ export interface SkillSkip {
39
39
  */
40
40
  detail?: string;
41
41
  }
42
+ /** Why a KEPT skill carries a warning (SPEC.md §3, #84). The skill still loads unchanged. */
43
+ export type SkillWarningReason = "oversized-description";
44
+ /** Maximum skill `description` length, in Unicode code points, before a warning (SPEC.md §3). */
45
+ export declare const SKILL_DESCRIPTION_MAX_CHARS = 1024;
46
+ export interface SkillWarning {
47
+ location: string;
48
+ reason: SkillWarningReason;
49
+ /** Description length in Unicode CODE POINTS (not bytes, not UTF-16 units). */
50
+ chars: number;
51
+ }
42
52
  /** Result of a list-only validate pass (SPEC.md §3, S3). */
43
53
  export interface SkillInventory {
44
54
  skills: SkillInfo[];
45
55
  skipped: SkillSkip[];
56
+ /** Kept skills that load but carry a warning (oversized description), in discovery order. */
57
+ warnings: SkillWarning[];
46
58
  }
47
59
  /** Options for loadSkills / listSkills (SPEC.md §3, S1/S2/S5). */
48
60
  export interface LoadSkillsOptions {
@@ -65,6 +77,8 @@ export interface SkillSource {
65
77
  * that stops being reported is indistinguishable from something that was finished.
66
78
  */
67
79
  skipped: SkillSkip[];
80
+ /** Exposed (post-allowlist) skills whose description is oversized; never modified (#84). */
81
+ warnings: SkillWarning[];
68
82
  }
69
83
  /**
70
84
  * Discover + validate skills from the same sources loadSkills accepts, returning