toolnexus 0.26.0 → 0.27.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/acp.d.ts CHANGED
@@ -48,15 +48,23 @@ export interface ACPOptions {
48
48
  /** One warm ACP connection: one child process, one session, alive across many
49
49
  * turns. `.generate` has exactly the shape {@link InProcessOptions.generate}
50
50
  * wants, so it plugs straight into {@link createInProcessClient}. */
51
+ /** Per-call options for {@link ACPClient.generate} / {@link ACPClient.generateStream}. */
52
+ export interface ACPGenerateOptions {
53
+ /** Cancels the turn: when it aborts mid-turn the `session/cancel` notification is sent,
54
+ * the turn's reply is awaited (bounded by `requestTimeoutMs`, default 30s) so the next
55
+ * turn never lands on a busy session, and the call rejects with an `AbortError`. The
56
+ * process and session stay warm. */
57
+ signal?: AbortSignal;
58
+ }
51
59
  export interface ACPClient {
52
- generate: (request: InProcessRequest) => Promise<InProcessResponse>;
60
+ generate: (request: InProcessRequest, opts?: ACPGenerateOptions) => Promise<InProcessResponse>;
53
61
  /** {@link generate}, streaming: `emit` fires as each `session/update` arrives —
54
62
  * agent_message_chunk → `text`, agent_thought_chunk → `thought` (via "acp"),
55
63
  * tool_call → `tool_call` and a completed/failed tool_call_update → `tool_result`
56
64
  * (both via "acp": the agent's own tools, reported, never executed by the loop).
57
65
  * Shaped for {@link InProcessOptions.generateStream}. The returned content is the
58
66
  * message text only. */
59
- generateStream: (request: InProcessRequest, emit: (event: StreamEvent) => void) => Promise<InProcessResponse>;
67
+ generateStream: (request: InProcessRequest, emit: (event: StreamEvent) => void, opts?: ACPGenerateOptions) => Promise<InProcessResponse>;
60
68
  /** Terminates the agent's process group. Idempotent — a second call is a no-op. */
61
69
  close: () => Promise<void>;
62
70
  /** The agent's raw `initialize` result. */
package/dist/acp.js CHANGED
@@ -298,7 +298,9 @@ export async function loadACP(opts) {
298
298
  // each generate() call attaches after whatever is already queued, and the
299
299
  // queue itself never rejects (a failed turn does not poison later ones).
300
300
  let turnQueue = Promise.resolve();
301
- async function promptOnce(request, emit) {
301
+ async function promptOnce(request, emit, signal) {
302
+ if (signal?.aborted)
303
+ throw acpAbortError();
302
304
  const text = renderPrompt(request);
303
305
  const id = `c-${++nextId}`;
304
306
  let content = "";
@@ -361,8 +363,30 @@ export async function loadACP(opts) {
361
363
  const timeout = new Promise((_, reject) => {
362
364
  timer = setTimeout(() => reject(new Error(`toolnexus: ACP turn timed out after ${permissionTimeoutMs}ms`)), permissionTimeoutMs);
363
365
  });
366
+ let onAbort;
367
+ const cancelled = new Promise((resolve) => {
368
+ if (!signal)
369
+ return;
370
+ onAbort = () => resolve("cancelled");
371
+ signal.addEventListener("abort", onAbort, { once: true });
372
+ });
364
373
  try {
365
- const msg = await Promise.race([replyPromise, timeout]);
374
+ const msg = await Promise.race([replyPromise, timeout, cancelled]);
375
+ if (msg === "cancelled") {
376
+ // ACP: session/cancel is a notification (no id); the agent then ends the turn with
377
+ // stopReason "cancelled". Wait (bounded) for that reply so the session is idle again.
378
+ write({ jsonrpc: "2.0", method: "session/cancel", params: { sessionId } });
379
+ const waitMs = opts.requestTimeoutMs && opts.requestTimeoutMs > 0 ? opts.requestTimeoutMs : 30000;
380
+ let wait;
381
+ await Promise.race([
382
+ replyPromise,
383
+ new Promise((r) => {
384
+ wait = setTimeout(r, waitMs);
385
+ }),
386
+ ]);
387
+ clearTimeout(wait);
388
+ throw acpAbortError();
389
+ }
366
390
  if (msg.error && msg.error.code === 0)
367
391
  throw new Error(msg.error.message);
368
392
  if (msg.error)
@@ -371,14 +395,16 @@ export async function loadACP(opts) {
371
395
  }
372
396
  finally {
373
397
  clearTimeout(timer);
398
+ if (onAbort)
399
+ signal.removeEventListener("abort", onAbort);
374
400
  pending.delete(id);
375
401
  updateSink = null;
376
402
  }
377
403
  }
378
- const generateStream = (request, emit) => {
404
+ const generateStream = (request, emit, callOpts) => {
379
405
  if (closed)
380
406
  return Promise.reject(new Error("toolnexus: ACP client is closed"));
381
- const turn = turnQueue.then(() => promptOnce(request, emit));
407
+ const turn = turnQueue.then(() => promptOnce(request, emit, callOpts?.signal));
382
408
  turnQueue = turn.then(() => undefined, () => undefined);
383
409
  return turn;
384
410
  };
@@ -400,13 +426,18 @@ export async function loadACP(opts) {
400
426
  if (t === "late")
401
427
  await Promise.race([childGone, new Promise((r) => setTimeout(r, 1000).unref())]);
402
428
  }
403
- const generate = (request) => generateStream(request);
429
+ const generate = (request, callOpts) => generateStream(request, undefined, callOpts);
404
430
  const client = { generate, generateStream, close, initializeResult };
405
431
  // Non-enumerable, undocumented — internal test hook only, so the public
406
432
  // ACPClient surface stays exactly {generate, generateStream, close}.
407
433
  Object.defineProperty(client, "_pid", { value: child.pid, enumerable: false });
408
434
  return client;
409
435
  }
436
+ function acpAbortError() {
437
+ const e = new Error("toolnexus: ACP turn cancelled");
438
+ e.name = "AbortError";
439
+ return e;
440
+ }
410
441
  /** Signals the child's whole process group (POSIX), else the child itself. Never throws. */
411
442
  function killGroup(pid, signal) {
412
443
  if (process.platform !== "win32") {
@@ -47,6 +47,13 @@ export declare const CLASSIFIER_REQUEST_IMAGE_BYTES: number;
47
47
  * Cloudflare-Jev images stay false until a live probe proves them.
48
48
  */
49
49
  export declare const CLASSIFIER_BACKENDS: Readonly<Record<ClassifierBackend, Readonly<ClassifierBackendPreset>>>;
50
+ /**
51
+ * The env var whose key may be sent to `baseUrl` (already `${NAME}`-expanded): the backend's own
52
+ * variable, and only when `baseUrl` is that backend's own host over https (OpenRouter also its
53
+ * subdomains). `""` when none may — `custom` has no preset key at all. A key is never sent to a
54
+ * host the caller did not name (#155, SPEC §8B).
55
+ */
56
+ export declare function classifierKeyEnv(backend: ClassifierBackend, baseUrl: string): string;
50
57
  /**
51
58
  * Raised BEFORE any network call when the backend table says the backend cannot take what the
52
59
  * request carries. Message, exact: `classifier: <backend> (<model>) does not support <feature>`.
@@ -377,6 +384,9 @@ export declare class Classifier {
377
384
  private readonly baseUrl;
378
385
  private readonly model;
379
386
  private readonly apiKeyEnv;
387
+ /** The caller NAMED apiKeyEnv: sent wherever baseUrl points. Otherwise the preset's own key,
388
+ * and only to its own host (classifierKeyEnv, #155). */
389
+ private readonly explicitKeyEnv;
380
390
  private readonly timeoutMs;
381
391
  private readonly backend;
382
392
  private readonly preset;
@@ -71,11 +71,36 @@ export const CLASSIFIER_BACKENDS = Object.freeze({
71
71
  custom: {
72
72
  baseUrl: "",
73
73
  model: DEFAULT_CLASSIFIER_MODEL,
74
- apiKeyEnv: DEFAULT_CLASSIFIER_API_KEY_ENV,
74
+ apiKeyEnv: "",
75
75
  wire: "systemone",
76
76
  imagesHeader: true,
77
77
  },
78
78
  });
79
+ /**
80
+ * The env var whose key may be sent to `baseUrl` (already `${NAME}`-expanded): the backend's own
81
+ * variable, and only when `baseUrl` is that backend's own host over https (OpenRouter also its
82
+ * subdomains). `""` when none may — `custom` has no preset key at all. A key is never sent to a
83
+ * host the caller did not name (#155, SPEC §8B).
84
+ */
85
+ export function classifierKeyEnv(backend, baseUrl) {
86
+ const p = Object.prototype.hasOwnProperty.call(CLASSIFIER_BACKENDS, backend) ? CLASSIFIER_BACKENDS[backend] : undefined;
87
+ if (!p || !p.apiKeyEnv || !p.baseUrl)
88
+ return "";
89
+ const hostOf = (u) => {
90
+ try {
91
+ const url = new URL(u);
92
+ return url.protocol === "https:" ? url.hostname.toLowerCase() : "";
93
+ }
94
+ catch {
95
+ return "";
96
+ }
97
+ };
98
+ const h = hostOf(baseUrl);
99
+ const own = hostOf(p.baseUrl);
100
+ if (!h || !own)
101
+ return "";
102
+ return h === own || (backend === "openrouter" && h.endsWith(".openrouter.ai")) ? p.apiKeyEnv : "";
103
+ }
79
104
  /**
80
105
  * Raised BEFORE any network call when the backend table says the backend cannot take what the
81
106
  * request carries. Message, exact: `classifier: <backend> (<model>) does not support <feature>`.
@@ -380,6 +405,9 @@ export class Classifier {
380
405
  baseUrl;
381
406
  model;
382
407
  apiKeyEnv;
408
+ /** The caller NAMED apiKeyEnv: sent wherever baseUrl points. Otherwise the preset's own key,
409
+ * and only to its own host (classifierKeyEnv, #155). */
410
+ explicitKeyEnv;
383
411
  timeoutMs;
384
412
  backend;
385
413
  preset;
@@ -403,6 +431,7 @@ export class Classifier {
403
431
  this.baseUrl = opts.baseUrl || preset.baseUrl;
404
432
  this.model = opts.model ?? preset.model;
405
433
  this.apiKeyEnv = opts.apiKeyEnv ?? preset.apiKeyEnv;
434
+ this.explicitKeyEnv = opts.apiKeyEnv !== undefined;
406
435
  const mismatch = backendMismatch(this.baseUrl, this.model);
407
436
  if (mismatch)
408
437
  throw new Error(mismatch);
@@ -745,7 +774,7 @@ export class Classifier {
745
774
  try {
746
775
  res = await doFetch(url, {
747
776
  method: "POST",
748
- headers: this.requestHeaders(extraHeaders),
777
+ headers: this.requestHeaders(extraHeaders, url),
749
778
  body: payload,
750
779
  signal: timer.signal,
751
780
  });
@@ -781,9 +810,10 @@ export class Classifier {
781
810
  }
782
811
  }
783
812
  /** Credential and `${ENV_VAR}` headers resolve AT CALL TIME. Never logged, never returned. */
784
- requestHeaders(extra = {}) {
813
+ requestHeaders(extra, url) {
785
814
  const headers = { "Content-Type": "application/json" };
786
- const key = process.env[this.apiKeyEnv];
815
+ const keyEnv = this.explicitKeyEnv ? this.apiKeyEnv : classifierKeyEnv(this.backend, url);
816
+ const key = keyEnv ? process.env[keyEnv] : undefined;
787
817
  if (key)
788
818
  headers["Authorization"] = `Bearer ${key}`;
789
819
  // The image opt-in header goes BEFORE host headers, which win.
@@ -1057,10 +1087,29 @@ function imageSize(format, b) {
1057
1087
  }
1058
1088
  return undefined;
1059
1089
  }
1060
- /** The System One reply inside a Cloudflare `/ai/run` envelope. Its error list is never echoed. */
1090
+ /**
1091
+ * `: <code> <message>` from errors[0] of a failed envelope (SPEC §8B), through the same redaction
1092
+ * and cap as cause(). Cloudflare reports a missing balance, a bad model id and a malformed
1093
+ * request alike as HTTP 200 + success:false, so this is the only thing that tells them apart.
1094
+ * `""` when there is nothing to show.
1095
+ */
1096
+ function cloudflareCause(env) {
1097
+ const errs = env !== null && typeof env === "object" ? env.errors : undefined;
1098
+ const e0 = Array.isArray(errs) ? errs[0] : undefined;
1099
+ if (e0 === null || typeof e0 !== "object")
1100
+ return "";
1101
+ const parts = [];
1102
+ if ((typeof e0.code === "number" || typeof e0.code === "string") && String(e0.code) !== "")
1103
+ parts.push(String(e0.code));
1104
+ if (typeof e0.message === "string" && e0.message !== "")
1105
+ parts.push(e0.message);
1106
+ const s = capErrorBody(redactErrorBody(parts.join(" ")));
1107
+ return s ? ": " + s : "";
1108
+ }
1109
+ /** The System One reply inside a Cloudflare `/ai/run` envelope. Only errors[0] is ever echoed. */
1061
1110
  function unwrapCloudflare(env) {
1062
1111
  if (env === null || typeof env !== "object" || Array.isArray(env) || env.success !== true) {
1063
- throw new Error("classifier: cloudflare: envelope not successful");
1112
+ throw new Error("classifier: cloudflare: envelope not successful" + cloudflareCause(env));
1064
1113
  }
1065
1114
  let result = env.result;
1066
1115
  if (result !== null && typeof result === "object" && !Array.isArray(result) && typeof result.state === "string") {
package/dist/mcp.js CHANGED
@@ -6,14 +6,16 @@
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 { AdoptedStdioTransport } from "./mcp2026/stdio.js";
11
10
  import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
12
11
  import { sanitize } from "./types.js";
13
12
  import { summarizePart } from "./content.js";
14
- import { probe, newOAuth, defaultTokenStore } from "./mcp2026/source.js";
15
13
  import { PendingError } from "./mcp2026/auth.js";
16
14
  export { Token, MemoryTokenStore } from "./mcp2026/auth.js";
15
+ // Loaded on first use, not at import: the 2026 client is only needed once a config is loaded,
16
+ // and the SDK's remote transports (streamable-HTTP, SSE) only when a remote server falls back.
17
+ let mcp2026;
18
+ const loadMcp2026 = () => (mcp2026 ??= import("./mcp2026/source.js"));
17
19
  // ---------------------------------------------------------------------------
18
20
  // MCP elicitation bridge (§10). A server can ask US for input mid-`tools/call`
19
21
  // (a reverse-request). We map it onto the one `waitFor`: form mode → kind:"input",
@@ -410,6 +412,10 @@ async function connectTransport(client, cfg, auth) {
410
412
  const expanded = expandEnvHeaders(cfg.headers);
411
413
  const requestInit = expanded ? { headers: expanded } : undefined;
412
414
  const fetch = auth ? bearerFetch(cfg.url, auth) : undefined;
415
+ const [{ StreamableHTTPClientTransport }, { SSEClientTransport }] = await Promise.all([
416
+ import("@modelcontextprotocol/sdk/client/streamableHttp.js"),
417
+ import("@modelcontextprotocol/sdk/client/sse.js"),
418
+ ]);
413
419
  try {
414
420
  await client.connect(new StreamableHTTPClientTransport(url, { requestInit, ...(fetch ? { fetch } : {}) }));
415
421
  }
@@ -469,6 +475,7 @@ function bearerFetch(resource, a) {
469
475
  * path, with the OAuth bearer attached for remote servers. Throws ⇒ the server failed.
470
476
  */
471
477
  async function connectServer(name, cfg, timeout, opts, store, sdkClient, onSdk) {
478
+ const { newOAuth, probe } = await loadMcp2026();
472
479
  const o = { waitFor: opts.waitFor, tokenStore: store, openUrl: opts.openUrl, signal: opts.signal, disableTools: opts.disableTools };
473
480
  const remote = isRemote(cfg);
474
481
  const oauth = remote ? newOAuth(cfg.auth, o, store) : undefined;
@@ -485,7 +492,10 @@ async function connectServer(name, cfg, timeout, opts, store, sdkClient, onSdk)
485
492
  return p;
486
493
  const client = sdkClient();
487
494
  onSdk(client);
488
- await raceTimeout(connectTransport(client, cfg, oauth ? { oauth, waitFor: opts.waitFor } : undefined), timeout, opts.signal);
495
+ const connecting = p.reuse
496
+ ? client.connect(new AdoptedStdioTransport(p.reuse.child, p.reuse.leftover)) // §2.2a: same child, no respawn
497
+ : connectTransport(client, cfg, oauth ? { oauth, waitFor: opts.waitFor } : undefined);
498
+ await raceTimeout(connecting, timeout, opts.signal);
489
499
  const defs = await raceTimeout(paginateTools(client, timeout), timeout, opts.signal);
490
500
  return { kind: "sdk", client, defs };
491
501
  }
@@ -499,7 +509,7 @@ export async function loadMcp(input, opts) {
499
509
  const clients = [];
500
510
  const servers = new Map();
501
511
  const skills = [];
502
- const store = defaultTokenStore({ tokenStore: opts?.tokenStore });
512
+ const store = (await loadMcp2026()).defaultTokenStore({ tokenStore: opts?.tokenStore });
503
513
  const waitFor = opts?.waitFor;
504
514
  const signal = opts?.signal;
505
515
  const onServer = opts?.onServer;
@@ -590,7 +600,7 @@ export async function listMcpTools(input, opts) {
590
600
  const toolsByServer = {};
591
601
  const status = {};
592
602
  const signal = opts?.signal;
593
- const store = defaultTokenStore({ tokenStore: opts?.tokenStore });
603
+ const store = (await loadMcp2026()).defaultTokenStore({ tokenStore: opts?.tokenStore });
594
604
  await Promise.all(Object.entries(config).map(async ([name, cfg]) => {
595
605
  if (!isEnabled(cfg)) {
596
606
  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) => {