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/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
package/dist/skill.js CHANGED
@@ -12,8 +12,16 @@
12
12
  import { readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
13
13
  import path from "node:path";
14
14
  import { pathToFileURL } from "node:url";
15
- import { parse as parseYaml } from "yaml";
15
+ import { createRequire } from "node:module";
16
16
  import { compareCodePoints, compareDiscovery, sortEntriesByName } from "./order.js";
17
+ // yaml is required on the first frontmatter parse, not at import, so a host with no skills never
18
+ // loads it. `require` keeps the parse synchronous (listSkills/loadSkills are sync); yaml's Node
19
+ // entry is CommonJS, so this is the same module instance an `import` would get.
20
+ let yamlParse;
21
+ function parseYaml(src) {
22
+ yamlParse ??= createRequire(import.meta.url)("yaml").parse;
23
+ return yamlParse(src);
24
+ }
17
25
  export const SKILL_TOOL_DESCRIPTION = `Load a specialized skill when the task at hand matches one of the skills listed in the system prompt.
18
26
 
19
27
  Use this tool to inject the skill's instructions and resources into current conversation. The output may contain detailed workflow guidance as well as references to scripts, files, etc in the same directory as the skill.
@@ -25,6 +33,8 @@ The skill name must match one of the skills listed in your system prompt.`;
25
33
  */
26
34
  export const SKILLS_PROMPT_PREAMBLE = "Skills provide specialized instructions and workflows for specific tasks.\n" +
27
35
  "Use the skill tool to load a skill when a task matches its description.";
36
+ /** Maximum skill `description` length, in Unicode code points, before a warning (SPEC.md §3). */
37
+ export const SKILL_DESCRIPTION_MAX_CHARS = 1024;
28
38
  /**
29
39
  * Parse YAML frontmatter (between the leading `---` fences) with a real YAML parser, so folded
30
40
  * (`>`)/literal (`|`) block scalars, quoting and multi-line values all resolve correctly.
@@ -304,6 +314,24 @@ function collectCandidates(opts) {
304
314
  cands.push(...candidatesFromDefs(opts.skills));
305
315
  return cands;
306
316
  }
317
+ /**
318
+ * Warn — never modify — skills whose description exceeds SKILL_DESCRIPTION_MAX_CHARS code
319
+ * points (SPEC.md §3, #84). Each warning is also sent to the same channel as the
320
+ * duplicate-name warning. Order follows the given skills (discovery order).
321
+ */
322
+ function describeWarnings(skills) {
323
+ const out = [];
324
+ for (const s of skills) {
325
+ if (s.description === undefined)
326
+ continue;
327
+ const chars = [...s.description].length;
328
+ if (chars > SKILL_DESCRIPTION_MAX_CHARS) {
329
+ console.warn(`[toolnexus] skill "${s.name}" (${s.location}) description is ${chars} chars, over ${SKILL_DESCRIPTION_MAX_CHARS} — kept unmodified`);
330
+ out.push({ location: s.location, reason: "oversized-description", chars });
331
+ }
332
+ }
333
+ return out;
334
+ }
307
335
  /** Dedupe candidates by name (first-wins); later duplicates become skips. */
308
336
  function mergeCandidates(cands) {
309
337
  const skills = {};
@@ -355,7 +383,8 @@ function applySkillsFilter(skills, filter) {
355
383
  export function listSkills(input) {
356
384
  const opts = typeof input === "string" || Array.isArray(input) ? { dirs: input } : input;
357
385
  const merged = mergeCandidates(collectCandidates(opts));
358
- return { skills: Object.values(merged.skills), skipped: merged.skipped };
386
+ const list = Object.values(merged.skills);
387
+ return { skills: list, skipped: merged.skipped, warnings: describeWarnings(list) };
359
388
  }
360
389
  /** Discover skills (dirs and/or data) and build the `skill` loader tool. */
361
390
  export function loadSkills(input) {
@@ -364,6 +393,7 @@ export function loadSkills(input) {
364
393
  const merged = mergeCandidates(collectCandidates(opts));
365
394
  const skills = applySkillsFilter(merged.skills, opts.filter);
366
395
  const skipped = merged.skipped;
396
+ const warnings = describeWarnings(Object.values(skills));
367
397
  const tool = {
368
398
  name: "skill",
369
399
  description: SKILL_TOOL_DESCRIPTION,
@@ -426,6 +456,7 @@ export function loadSkills(input) {
426
456
  skills,
427
457
  tool,
428
458
  skipped,
459
+ warnings,
429
460
  prompt() {
430
461
  const described = Object.values(skills)
431
462
  .filter((s) => s.description !== undefined)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolnexus",
3
- "version": "0.26.0",
3
+ "version": "0.27.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/acp.ts CHANGED
@@ -86,15 +86,28 @@ export interface ACPOptions {
86
86
  /** One warm ACP connection: one child process, one session, alive across many
87
87
  * turns. `.generate` has exactly the shape {@link InProcessOptions.generate}
88
88
  * wants, so it plugs straight into {@link createInProcessClient}. */
89
+ /** Per-call options for {@link ACPClient.generate} / {@link ACPClient.generateStream}. */
90
+ export interface ACPGenerateOptions {
91
+ /** Cancels the turn: when it aborts mid-turn the `session/cancel` notification is sent,
92
+ * the turn's reply is awaited (bounded by `requestTimeoutMs`, default 30s) so the next
93
+ * turn never lands on a busy session, and the call rejects with an `AbortError`. The
94
+ * process and session stay warm. */
95
+ signal?: AbortSignal
96
+ }
97
+
89
98
  export interface ACPClient {
90
- generate: (request: InProcessRequest) => Promise<InProcessResponse>
99
+ generate: (request: InProcessRequest, opts?: ACPGenerateOptions) => Promise<InProcessResponse>
91
100
  /** {@link generate}, streaming: `emit` fires as each `session/update` arrives —
92
101
  * agent_message_chunk → `text`, agent_thought_chunk → `thought` (via "acp"),
93
102
  * tool_call → `tool_call` and a completed/failed tool_call_update → `tool_result`
94
103
  * (both via "acp": the agent's own tools, reported, never executed by the loop).
95
104
  * Shaped for {@link InProcessOptions.generateStream}. The returned content is the
96
105
  * message text only. */
97
- generateStream: (request: InProcessRequest, emit: (event: StreamEvent) => void) => Promise<InProcessResponse>
106
+ generateStream: (
107
+ request: InProcessRequest,
108
+ emit: (event: StreamEvent) => void,
109
+ opts?: ACPGenerateOptions,
110
+ ) => Promise<InProcessResponse>
98
111
  /** Terminates the agent's process group. Idempotent — a second call is a no-op. */
99
112
  close: () => Promise<void>
100
113
  /** The agent's raw `initialize` result. */
@@ -375,7 +388,12 @@ export async function loadACP(opts: ACPOptions): Promise<ACPClient> {
375
388
  // queue itself never rejects (a failed turn does not poison later ones).
376
389
  let turnQueue: Promise<unknown> = Promise.resolve()
377
390
 
378
- async function promptOnce(request: InProcessRequest, emit?: (event: StreamEvent) => void): Promise<InProcessResponse> {
391
+ async function promptOnce(
392
+ request: InProcessRequest,
393
+ emit?: (event: StreamEvent) => void,
394
+ signal?: AbortSignal,
395
+ ): Promise<InProcessResponse> {
396
+ if (signal?.aborted) throw acpAbortError()
379
397
  const text = renderPrompt(request)
380
398
  const id = `c-${++nextId}`
381
399
 
@@ -440,21 +458,48 @@ export async function loadACP(opts: ACPOptions): Promise<ACPClient> {
440
458
  )
441
459
  })
442
460
 
461
+ let onAbort: (() => void) | undefined
462
+ const cancelled = new Promise<"cancelled">((resolve) => {
463
+ if (!signal) return
464
+ onAbort = () => resolve("cancelled")
465
+ signal.addEventListener("abort", onAbort, { once: true })
466
+ })
467
+
443
468
  try {
444
- const msg = await Promise.race([replyPromise, timeout])
469
+ const msg = await Promise.race([replyPromise, timeout, cancelled])
470
+ if (msg === "cancelled") {
471
+ // ACP: session/cancel is a notification (no id); the agent then ends the turn with
472
+ // stopReason "cancelled". Wait (bounded) for that reply so the session is idle again.
473
+ write({ jsonrpc: "2.0", method: "session/cancel", params: { sessionId } })
474
+ const waitMs = opts.requestTimeoutMs && opts.requestTimeoutMs > 0 ? opts.requestTimeoutMs : 30000
475
+ let wait: NodeJS.Timeout | undefined
476
+ await Promise.race([
477
+ replyPromise,
478
+ new Promise((r) => {
479
+ wait = setTimeout(r, waitMs)
480
+ }),
481
+ ])
482
+ clearTimeout(wait)
483
+ throw acpAbortError()
484
+ }
445
485
  if (msg.error && msg.error.code === 0) throw new Error(msg.error.message)
446
486
  if (msg.error) throw new Error(`toolnexus: ACP error ${msg.error.code}: ${msg.error.message}`)
447
487
  return { content }
448
488
  } finally {
449
489
  clearTimeout(timer)
490
+ if (onAbort) signal!.removeEventListener("abort", onAbort)
450
491
  pending.delete(id)
451
492
  updateSink = null
452
493
  }
453
494
  }
454
495
 
455
- const generateStream = (request: InProcessRequest, emit?: (event: StreamEvent) => void): Promise<InProcessResponse> => {
496
+ const generateStream = (
497
+ request: InProcessRequest,
498
+ emit?: (event: StreamEvent) => void,
499
+ callOpts?: ACPGenerateOptions,
500
+ ): Promise<InProcessResponse> => {
456
501
  if (closed) return Promise.reject(new Error("toolnexus: ACP client is closed"))
457
- const turn = turnQueue.then(() => promptOnce(request, emit))
502
+ const turn = turnQueue.then(() => promptOnce(request, emit, callOpts?.signal))
458
503
  turnQueue = turn.then(
459
504
  () => undefined,
460
505
  () => undefined,
@@ -478,7 +523,8 @@ export async function loadACP(opts: ACPOptions): Promise<ACPClient> {
478
523
  if (t === "late") await Promise.race([childGone, new Promise((r) => setTimeout(r, 1000).unref())])
479
524
  }
480
525
 
481
- const generate = (request: InProcessRequest): Promise<InProcessResponse> => generateStream(request)
526
+ const generate = (request: InProcessRequest, callOpts?: ACPGenerateOptions): Promise<InProcessResponse> =>
527
+ generateStream(request, undefined, callOpts)
482
528
 
483
529
  const client: ACPClient = { generate, generateStream, close, initializeResult }
484
530
  // Non-enumerable, undocumented — internal test hook only, so the public
@@ -487,6 +533,12 @@ export async function loadACP(opts: ACPOptions): Promise<ACPClient> {
487
533
  return client
488
534
  }
489
535
 
536
+ function acpAbortError(): Error {
537
+ const e = new Error("toolnexus: ACP turn cancelled")
538
+ e.name = "AbortError"
539
+ return e
540
+ }
541
+
490
542
  /** Signals the child's whole process group (POSIX), else the child itself. Never throws. */
491
543
  function killGroup(pid: number, signal: NodeJS.Signals): void {
492
544
  if (process.platform !== "win32") {
package/src/classifier.ts CHANGED
@@ -104,12 +104,35 @@ export const CLASSIFIER_BACKENDS: Readonly<Record<ClassifierBackend, Readonly<Cl
104
104
  custom: {
105
105
  baseUrl: "",
106
106
  model: DEFAULT_CLASSIFIER_MODEL,
107
- apiKeyEnv: DEFAULT_CLASSIFIER_API_KEY_ENV,
107
+ apiKeyEnv: "",
108
108
  wire: "systemone",
109
109
  imagesHeader: true,
110
110
  },
111
111
  })
112
112
 
113
+ /**
114
+ * The env var whose key may be sent to `baseUrl` (already `${NAME}`-expanded): the backend's own
115
+ * variable, and only when `baseUrl` is that backend's own host over https (OpenRouter also its
116
+ * subdomains). `""` when none may — `custom` has no preset key at all. A key is never sent to a
117
+ * host the caller did not name (#155, SPEC §8B).
118
+ */
119
+ export function classifierKeyEnv(backend: ClassifierBackend, baseUrl: string): string {
120
+ const p = Object.prototype.hasOwnProperty.call(CLASSIFIER_BACKENDS, backend) ? CLASSIFIER_BACKENDS[backend] : undefined
121
+ if (!p || !p.apiKeyEnv || !p.baseUrl) return ""
122
+ const hostOf = (u: string): string => {
123
+ try {
124
+ const url = new URL(u)
125
+ return url.protocol === "https:" ? url.hostname.toLowerCase() : ""
126
+ } catch {
127
+ return ""
128
+ }
129
+ }
130
+ const h = hostOf(baseUrl)
131
+ const own = hostOf(p.baseUrl)
132
+ if (!h || !own) return ""
133
+ return h === own || (backend === "openrouter" && h.endsWith(".openrouter.ai")) ? p.apiKeyEnv : ""
134
+ }
135
+
113
136
  /**
114
137
  * Raised BEFORE any network call when the backend table says the backend cannot take what the
115
138
  * request carries. Message, exact: `classifier: <backend> (<model>) does not support <feature>`.
@@ -650,6 +673,9 @@ export class Classifier {
650
673
  private readonly baseUrl: string
651
674
  private readonly model: string
652
675
  private readonly apiKeyEnv: string
676
+ /** The caller NAMED apiKeyEnv: sent wherever baseUrl points. Otherwise the preset's own key,
677
+ * and only to its own host (classifierKeyEnv, #155). */
678
+ private readonly explicitKeyEnv: boolean
653
679
  private readonly timeoutMs: number
654
680
  private readonly backend: ClassifierBackend
655
681
  private readonly preset: Readonly<ClassifierBackendPreset>
@@ -675,6 +701,7 @@ export class Classifier {
675
701
  this.baseUrl = opts.baseUrl || preset.baseUrl
676
702
  this.model = opts.model ?? preset.model
677
703
  this.apiKeyEnv = opts.apiKeyEnv ?? preset.apiKeyEnv
704
+ this.explicitKeyEnv = opts.apiKeyEnv !== undefined
678
705
  const mismatch = backendMismatch(this.baseUrl, this.model)
679
706
  if (mismatch) throw new Error(mismatch)
680
707
  this.timeoutMs = opts.timeoutMs ?? DEFAULT_CLASSIFIER_TIMEOUT_MS
@@ -1019,7 +1046,7 @@ export class Classifier {
1019
1046
  try {
1020
1047
  res = await doFetch(url, {
1021
1048
  method: "POST",
1022
- headers: this.requestHeaders(extraHeaders),
1049
+ headers: this.requestHeaders(extraHeaders, url),
1023
1050
  body: payload,
1024
1051
  signal: timer.signal,
1025
1052
  })
@@ -1049,9 +1076,10 @@ export class Classifier {
1049
1076
  }
1050
1077
 
1051
1078
  /** Credential and `${ENV_VAR}` headers resolve AT CALL TIME. Never logged, never returned. */
1052
- private requestHeaders(extra: Record<string, string> = {}): Record<string, string> {
1079
+ private requestHeaders(extra: Record<string, string>, url: string): Record<string, string> {
1053
1080
  const headers: Record<string, string> = { "Content-Type": "application/json" }
1054
- const key = process.env[this.apiKeyEnv]
1081
+ const keyEnv = this.explicitKeyEnv ? this.apiKeyEnv : classifierKeyEnv(this.backend, url)
1082
+ const key = keyEnv ? process.env[keyEnv] : undefined
1055
1083
  if (key) headers["Authorization"] = `Bearer ${key}`
1056
1084
  // The image opt-in header goes BEFORE host headers, which win.
1057
1085
  for (const [k, v] of Object.entries(extra)) headers[k] = v
@@ -1341,10 +1369,27 @@ function imageSize(format: string, b: Uint8Array): [number, number] | undefined
1341
1369
  return undefined
1342
1370
  }
1343
1371
 
1344
- /** The System One reply inside a Cloudflare `/ai/run` envelope. Its error list is never echoed. */
1372
+ /**
1373
+ * `: <code> <message>` from errors[0] of a failed envelope (SPEC §8B), through the same redaction
1374
+ * and cap as cause(). Cloudflare reports a missing balance, a bad model id and a malformed
1375
+ * request alike as HTTP 200 + success:false, so this is the only thing that tells them apart.
1376
+ * `""` when there is nothing to show.
1377
+ */
1378
+ function cloudflareCause(env: unknown): string {
1379
+ const errs = env !== null && typeof env === "object" ? (env as any).errors : undefined
1380
+ const e0 = Array.isArray(errs) ? errs[0] : undefined
1381
+ if (e0 === null || typeof e0 !== "object") return ""
1382
+ const parts: string[] = []
1383
+ if ((typeof e0.code === "number" || typeof e0.code === "string") && String(e0.code) !== "") parts.push(String(e0.code))
1384
+ if (typeof e0.message === "string" && e0.message !== "") parts.push(e0.message)
1385
+ const s = capErrorBody(redactErrorBody(parts.join(" ")))
1386
+ return s ? ": " + s : ""
1387
+ }
1388
+
1389
+ /** The System One reply inside a Cloudflare `/ai/run` envelope. Only errors[0] is ever echoed. */
1345
1390
  function unwrapCloudflare(env: unknown): unknown {
1346
1391
  if (env === null || typeof env !== "object" || Array.isArray(env) || (env as any).success !== true) {
1347
- throw new Error("classifier: cloudflare: envelope not successful")
1392
+ throw new Error("classifier: cloudflare: envelope not successful" + cloudflareCause(env))
1348
1393
  }
1349
1394
  let result = (env as any).result
1350
1395
  if (result !== null && typeof result === "object" && !Array.isArray(result) && typeof result.state === "string") {
package/src/mcp.ts CHANGED
@@ -6,18 +6,22 @@
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 type { Tool as McpToolDef, ElicitRequest, ElicitResult } from "@modelcontextprotocol/sdk/types.js"
12
11
  import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"
13
12
  import { type JSONSchema, type Tool, type ToolContext, type ToolHints, type ToolResult, type McpStatus, type Request, type Answer, sanitize } from "./types.js"
14
13
  import { type ContentPart, summarizePart } from "./content.js"
15
14
  import type { SkillDef } from "./skill.js"
16
- import { probe, newOAuth, defaultTokenStore, type AuthConfig, type ModernServer, type Load2026Options } from "./mcp2026/source.js"
15
+ import { type AuthConfig, type ModernServer, type Load2026Options } from "./mcp2026/source.js"
17
16
  import { PendingError, type OAuth, type TokenStore } from "./mcp2026/auth.js"
18
17
  export { Token, MemoryTokenStore, type TokenStore } from "./mcp2026/auth.js"
19
18
  export type { AuthConfig } from "./mcp2026/source.js"
20
19
 
20
+ // Loaded on first use, not at import: the 2026 client is only needed once a config is loaded,
21
+ // and the SDK's remote transports (streamable-HTTP, SSE) only when a remote server falls back.
22
+ let mcp2026: Promise<typeof import("./mcp2026/source.js")> | undefined
23
+ const loadMcp2026 = () => (mcp2026 ??= import("./mcp2026/source.js"))
24
+
21
25
  // ---------------------------------------------------------------------------
22
26
  // MCP elicitation bridge (§10). A server can ask US for input mid-`tools/call`
23
27
  // (a reverse-request). We map it onto the one `waitFor`: form mode → kind:"input",
@@ -477,6 +481,10 @@ async function connectTransport(client: Client, cfg: ServerConfig, auth?: Fallba
477
481
  const expanded = expandEnvHeaders(cfg.headers)
478
482
  const requestInit = expanded ? { headers: expanded } : undefined
479
483
  const fetch = auth ? bearerFetch(cfg.url, auth) : undefined
484
+ const [{ StreamableHTTPClientTransport }, { SSEClientTransport }] = await Promise.all([
485
+ import("@modelcontextprotocol/sdk/client/streamableHttp.js"),
486
+ import("@modelcontextprotocol/sdk/client/sse.js"),
487
+ ])
480
488
  try {
481
489
  await client.connect(new StreamableHTTPClientTransport(url, { requestInit, ...(fetch ? { fetch } : {}) }))
482
490
  } catch {
@@ -552,6 +560,7 @@ type Connected =
552
560
  * path, with the OAuth bearer attached for remote servers. Throws ⇒ the server failed.
553
561
  */
554
562
  async function connectServer(name: string, cfg: ServerConfig, timeout: number, opts: McpLoadOptions, store: TokenStore, sdkClient: () => Client, onSdk: (c: Client) => void): Promise<Connected> {
563
+ const { newOAuth, probe } = await loadMcp2026()
555
564
  const o: Load2026Options = { waitFor: opts.waitFor, tokenStore: store, openUrl: opts.openUrl, signal: opts.signal, disableTools: opts.disableTools }
556
565
  const remote = isRemote(cfg)
557
566
  const oauth = remote ? newOAuth(cfg.auth, o, store) : undefined
@@ -570,7 +579,10 @@ async function connectServer(name: string, cfg: ServerConfig, timeout: number, o
570
579
  if (p.kind === "modern") return p
571
580
  const client = sdkClient()
572
581
  onSdk(client)
573
- await raceTimeout(connectTransport(client, cfg, oauth ? { oauth, waitFor: opts.waitFor } : undefined), timeout, opts.signal)
582
+ const connecting = p.reuse
583
+ ? client.connect(new AdoptedStdioTransport(p.reuse.child, p.reuse.leftover)) // §2.2a: same child, no respawn
584
+ : connectTransport(client, cfg, oauth ? { oauth, waitFor: opts.waitFor } : undefined)
585
+ await raceTimeout(connecting, timeout, opts.signal)
574
586
  const defs = await raceTimeout(paginateTools(client, timeout), timeout, opts.signal)
575
587
  return { kind: "sdk", client, defs }
576
588
  }
@@ -596,7 +608,7 @@ export async function loadMcp(
596
608
  const clients: { close(): Promise<void> }[] = []
597
609
  const servers = new Map<string, ServerConn>()
598
610
  const skills: SkillDef[] = []
599
- const store = defaultTokenStore({ tokenStore: opts?.tokenStore })
611
+ const store = (await loadMcp2026()).defaultTokenStore({ tokenStore: opts?.tokenStore })
600
612
  const waitFor = opts?.waitFor
601
613
  const signal = opts?.signal
602
614
  const onServer = opts?.onServer
@@ -701,7 +713,7 @@ export async function listMcpTools(input: string | object, opts?: McpLoadOptions
701
713
  const toolsByServer: Record<string, ToolInfo[]> = {}
702
714
  const status: Record<string, McpStatus> = {}
703
715
  const signal = opts?.signal
704
- const store = defaultTokenStore({ tokenStore: opts?.tokenStore })
716
+ const store = (await loadMcp2026()).defaultTokenStore({ tokenStore: opts?.tokenStore })
705
717
 
706
718
  await Promise.all(
707
719
  Object.entries(config).map(async ([name, cfg]) => {
@@ -8,7 +8,8 @@ import { randomBytes } from "node:crypto"
8
8
  import { Mcp2026Client } from "./client.js"
9
9
  import { HttpStatusError, NotJsonRpcError, PROTOCOL_VERSION, RpcError, SKILLS_EXT, type Obj } from "./protocol.js"
10
10
  import { LoopbackAuthorizer, MemoryTokenStore, OAuth, PauseAuthorizer, PendingError, type TokenStore } from "./auth.js"
11
- import { spawnStdio, StdioClosedError } from "./stdio.js"
11
+ import { spawnStdio, StdioClosedError, type StdioTransport } from "./stdio.js"
12
+ import type { ChildProcess } from "node:child_process"
12
13
  import { rank } from "./toolsearch.js"
13
14
  import type { Answer, JSONSchema, Request, Tool, ToolContext, ToolResult } from "../types.js"
14
15
  import { sanitize } from "../types.js"
@@ -88,7 +89,9 @@ async function authed(oauth: OAuth | undefined, waitFor: WaitFor | undefined, f:
88
89
  }
89
90
 
90
91
  /** What the probe decided. `fallback` ⇒ take the frozen SDK path; anything thrown ⇒ status failed. */
91
- export type Probe = { kind: "modern"; server: ModernServer } | { kind: "fallback" }
92
+ export type Probe =
93
+ | { kind: "modern"; server: ModernServer }
94
+ | { kind: "fallback"; reuse?: { child: ChildProcess; leftover: Buffer } }
92
95
 
93
96
  /** §2.2a: which discover failures mean "old server". */
94
97
  function isFallbackError(e: unknown): boolean {
@@ -115,11 +118,14 @@ export interface ProbeTarget {
115
118
  /** Discover first (§2.2). Resolves `modern` (tools listed) or `fallback`; throws on a load failure. */
116
119
  export async function probe(t: ProbeTarget, o: Load2026Options): Promise<Probe> {
117
120
  let client: Mcp2026Client
121
+ let stdio: StdioTransport | undefined
118
122
  if (t.remote) {
119
123
  client = new Mcp2026Client(t.remote.url, { headers: t.remote.headers, auth: t.remote.oauth, timeoutMs: t.timeout })
120
124
  } else {
121
- const tr = await spawnStdio(t.local!.argv, t.local!.env, t.local!.cwd)
122
- client = new Mcp2026Client("", { transport: tr, timeoutMs: t.timeout })
125
+ stdio = await spawnStdio(t.local!.argv, t.local!.env, t.local!.cwd)
126
+ // Stop parsing right after the discover reply so an old server's child can be handed over.
127
+ stdio.holdAfterNextResponse()
128
+ client = new Mcp2026Client("", { transport: stdio, timeoutMs: t.timeout })
123
129
  }
124
130
  const oauth = t.remote?.oauth
125
131
  let disc: Obj
@@ -129,6 +135,8 @@ export async function probe(t: ProbeTarget, o: Load2026Options): Promise<Probe>
129
135
  disc = await authed(oauth, o.waitFor, () => client.call("server/discover", {}, { timeoutMs, signal: o.signal }))
130
136
  } catch (e) {
131
137
  const stdioTimeout = !!t.local && e instanceof Error && e.message === "stdio request timed out"
138
+ // §2.2a: a fast JSON-RPC error over stdio ⇒ the SAME child continues into initialize.
139
+ if (stdio && e instanceof RpcError && !o.signal?.aborted) return { kind: "fallback", reuse: stdio.detach() }
132
140
  await client.close().catch(() => {})
133
141
  if (o.signal?.aborted) throw e
134
142
  if (e instanceof StdioClosedError) throw e
@@ -136,9 +144,11 @@ export async function probe(t: ProbeTarget, o: Load2026Options): Promise<Probe>
136
144
  throw e
137
145
  }
138
146
  if (!supports2026(disc)) {
147
+ if (stdio) return { kind: "fallback", reuse: stdio.detach() }
139
148
  await client.close().catch(() => {})
140
149
  return { kind: "fallback" }
141
150
  }
151
+ stdio?.release()
142
152
  try {
143
153
  const server = new ModernServer(t, client, disc, o)
144
154
  await server.load()