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/src/index.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/src/mcp.ts CHANGED
@@ -6,18 +6,24 @@
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 type { jsonSchemaValidator } from "@modelcontextprotocol/sdk/validation"
11
+ import { AdoptedStdioTransport } from "./mcp2026/stdio.js"
11
12
  import type { Tool as McpToolDef, ElicitRequest, ElicitResult } from "@modelcontextprotocol/sdk/types.js"
12
13
  import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"
13
14
  import { type JSONSchema, type Tool, type ToolContext, type ToolHints, type ToolResult, type McpStatus, type Request, type Answer, sanitize } from "./types.js"
14
15
  import { type ContentPart, summarizePart } from "./content.js"
15
16
  import type { SkillDef } from "./skill.js"
16
- import { probe, newOAuth, defaultTokenStore, type AuthConfig, type ModernServer, type Load2026Options } from "./mcp2026/source.js"
17
+ import { type AuthConfig, type ModernServer, type Load2026Options } from "./mcp2026/source.js"
17
18
  import { PendingError, type OAuth, type TokenStore } from "./mcp2026/auth.js"
18
19
  export { Token, MemoryTokenStore, type TokenStore } from "./mcp2026/auth.js"
19
20
  export type { AuthConfig } from "./mcp2026/source.js"
20
21
 
22
+ // Loaded on first use, not at import: the 2026 client is only needed once a config is loaded,
23
+ // and the SDK's remote transports (streamable-HTTP, SSE) only when a remote server falls back.
24
+ let mcp2026: Promise<typeof import("./mcp2026/source.js")> | undefined
25
+ const loadMcp2026 = () => (mcp2026 ??= import("./mcp2026/source.js"))
26
+
21
27
  // ---------------------------------------------------------------------------
22
28
  // MCP elicitation bridge (§10). A server can ask US for input mid-`tools/call`
23
29
  // (a reverse-request). We map it onto the one `waitFor`: form mode → kind:"input",
@@ -460,8 +466,23 @@ function resourceTools(servers: Map<string, ServerConn>): Tool[] {
460
466
  * exists. Separated from connect so the caller ALWAYS holds a closeable reference — even if the
461
467
  * connect is later abandoned by a timeout/abort, `client.close()` still terminates any spawned
462
468
  * stdio child (no orphaned processes on cancellation). */
469
+ /**
470
+ * The SDK's default validator, built on first use instead of per Client. Building an Ajv instance
471
+ * (meta-schemas + formats) costs ~2-3 ms on every connect, yet it is only consulted when a tool
472
+ * declares an `outputSchema`. Same validator, same results — just not paid for up front.
473
+ */
474
+ class LazyAjvValidator implements jsonSchemaValidator {
475
+ private inner?: AjvJsonSchemaValidator
476
+ getValidator<T>(schema: Parameters<jsonSchemaValidator["getValidator"]>[0]) {
477
+ return (this.inner ??= new AjvJsonSchemaValidator()).getValidator<T>(schema)
478
+ }
479
+ }
480
+
463
481
  function newMcpClient(waitFor?: (request: Request) => Promise<Answer>): Client {
464
- const client = new Client({ name: "toolnexus", version: "0.1.0" }, { capabilities: waitFor ? { elicitation: {} } : {} })
482
+ const client = new Client(
483
+ { name: "toolnexus", version: "0.1.0" },
484
+ { capabilities: waitFor ? { elicitation: {} } : {}, jsonSchemaValidator: new LazyAjvValidator() },
485
+ )
465
486
  if (waitFor) {
466
487
  client.setRequestHandler(ElicitRequestSchema, async (request) =>
467
488
  answerToElicitResult(await waitFor(elicitationToRequest(request.params))),
@@ -477,6 +498,10 @@ async function connectTransport(client: Client, cfg: ServerConfig, auth?: Fallba
477
498
  const expanded = expandEnvHeaders(cfg.headers)
478
499
  const requestInit = expanded ? { headers: expanded } : undefined
479
500
  const fetch = auth ? bearerFetch(cfg.url, auth) : undefined
501
+ const [{ StreamableHTTPClientTransport }, { SSEClientTransport }] = await Promise.all([
502
+ import("@modelcontextprotocol/sdk/client/streamableHttp.js"),
503
+ import("@modelcontextprotocol/sdk/client/sse.js"),
504
+ ])
480
505
  try {
481
506
  await client.connect(new StreamableHTTPClientTransport(url, { requestInit, ...(fetch ? { fetch } : {}) }))
482
507
  } catch {
@@ -552,6 +577,7 @@ type Connected =
552
577
  * path, with the OAuth bearer attached for remote servers. Throws ⇒ the server failed.
553
578
  */
554
579
  async function connectServer(name: string, cfg: ServerConfig, timeout: number, opts: McpLoadOptions, store: TokenStore, sdkClient: () => Client, onSdk: (c: Client) => void): Promise<Connected> {
580
+ const { newOAuth, probe } = await loadMcp2026()
555
581
  const o: Load2026Options = { waitFor: opts.waitFor, tokenStore: store, openUrl: opts.openUrl, signal: opts.signal, disableTools: opts.disableTools }
556
582
  const remote = isRemote(cfg)
557
583
  const oauth = remote ? newOAuth(cfg.auth, o, store) : undefined
@@ -570,7 +596,10 @@ async function connectServer(name: string, cfg: ServerConfig, timeout: number, o
570
596
  if (p.kind === "modern") return p
571
597
  const client = sdkClient()
572
598
  onSdk(client)
573
- await raceTimeout(connectTransport(client, cfg, oauth ? { oauth, waitFor: opts.waitFor } : undefined), timeout, opts.signal)
599
+ const connecting = p.reuse
600
+ ? client.connect(new AdoptedStdioTransport(p.reuse.child, p.reuse.leftover)) // §2.2a: same child, no respawn
601
+ : connectTransport(client, cfg, oauth ? { oauth, waitFor: opts.waitFor } : undefined)
602
+ await raceTimeout(connecting, timeout, opts.signal)
574
603
  const defs = await raceTimeout(paginateTools(client, timeout), timeout, opts.signal)
575
604
  return { kind: "sdk", client, defs }
576
605
  }
@@ -596,7 +625,7 @@ export async function loadMcp(
596
625
  const clients: { close(): Promise<void> }[] = []
597
626
  const servers = new Map<string, ServerConn>()
598
627
  const skills: SkillDef[] = []
599
- const store = defaultTokenStore({ tokenStore: opts?.tokenStore })
628
+ const store = (await loadMcp2026()).defaultTokenStore({ tokenStore: opts?.tokenStore })
600
629
  const waitFor = opts?.waitFor
601
630
  const signal = opts?.signal
602
631
  const onServer = opts?.onServer
@@ -701,7 +730,7 @@ export async function listMcpTools(input: string | object, opts?: McpLoadOptions
701
730
  const toolsByServer: Record<string, ToolInfo[]> = {}
702
731
  const status: Record<string, McpStatus> = {}
703
732
  const signal = opts?.signal
704
- const store = defaultTokenStore({ tokenStore: opts?.tokenStore })
733
+ const store = (await loadMcp2026()).defaultTokenStore({ tokenStore: opts?.tokenStore })
705
734
 
706
735
  await Promise.all(
707
736
  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()
@@ -4,10 +4,12 @@
4
4
  * pending call instead of hanging. No headers, no auth.
5
5
  */
6
6
  import { spawn, type ChildProcess } from "node:child_process"
7
- import { createInterface } from "node:readline"
8
7
  import type { Readable } from "node:stream"
9
8
  import type { Transport } from "./client.js"
10
9
  import type { Obj } from "./protocol.js"
10
+ import type { Transport as SdkTransport } from "@modelcontextprotocol/sdk/shared/transport.js"
11
+ import { ReadBuffer, serializeMessage } from "@modelcontextprotocol/sdk/shared/stdio.js"
12
+ import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"
11
13
 
12
14
  /** The child exited / closed stdout before answering. */
13
15
  export class StdioClosedError extends Error {}
@@ -18,33 +20,89 @@ export class StdioTransport implements Transport {
18
20
  private done: Promise<void>
19
21
  private child: ChildProcess
20
22
 
23
+ private buf: Buffer = Buffer.alloc(0)
24
+ private onData: (chunk: Buffer) => void
25
+ private onEnd: () => void
26
+ private detached = false
27
+ private hold = false
28
+ private held = false
29
+
21
30
  constructor(child: ChildProcess) {
22
31
  this.child = child
23
- const rl = createInterface({ input: child.stdout as Readable, crlfDelay: Infinity })
24
- rl.on("line", (line) => {
25
- let m: any
26
- try {
27
- m = JSON.parse(line)
28
- } catch {
29
- return // noise
30
- }
31
- if (!m || typeof m !== "object" || m.method !== undefined || m.id === undefined || m.id === null) return // not a response
32
- const key = JSON.stringify(m.id)
33
- const f = this.pending.get(key)
34
- this.pending.delete(key)
35
- f?.(m)
36
- })
37
- this.done = new Promise((r) =>
38
- rl.once("close", () => {
39
- this.err ??= new StdioClosedError("stdio server closed its stdout")
40
- for (const f of this.pending.values()) f({ __closed: true })
41
- this.pending.clear()
42
- r()
43
- }),
44
- )
32
+ // Line-exact own reader (no readline): detach() can stop it and hand over unread bytes.
33
+ let closed!: () => void
34
+ this.done = new Promise<void>((r) => (closed = r))
35
+ this.onData = (chunk: Buffer) => {
36
+ this.buf = this.buf.length ? Buffer.concat([this.buf, chunk]) : chunk
37
+ this.drain()
38
+ }
39
+ this.onEnd = () => {
40
+ this.err ??= new StdioClosedError("stdio server closed its stdout")
41
+ for (const f of this.pending.values()) f({ __closed: true })
42
+ this.pending.clear()
43
+ closed()
44
+ }
45
+ const out = child.stdout as Readable
46
+ out.on("data", this.onData)
47
+ out.once("end", this.onEnd)
48
+ out.once("close", this.onEnd)
45
49
  child.stdin?.on("error", () => {}) // EPIPE after the server died surfaces as a closed stdout
46
50
  }
47
51
 
52
+ private drain(): void {
53
+ let nl: number
54
+ while (!this.detached && !this.held && (nl = this.buf.indexOf(10)) >= 0) {
55
+ const line = this.buf.subarray(0, nl).toString("utf8").replace(/\r$/, "")
56
+ this.buf = this.buf.subarray(nl + 1)
57
+ this.onLine(line)
58
+ }
59
+ }
60
+
61
+ /** Probe mode: after the next delivered response, stop parsing (bytes stay buffered for detach). */
62
+ holdAfterNextResponse(): void {
63
+ this.hold = true
64
+ }
65
+
66
+ /** Leave probe mode and parse whatever was held. */
67
+ release(): void {
68
+ this.hold = false
69
+ this.held = false
70
+ this.drain()
71
+ }
72
+
73
+ private onLine(line: string): void {
74
+ let m: any
75
+ try {
76
+ m = JSON.parse(line)
77
+ } catch {
78
+ return // noise
79
+ }
80
+ if (!m || typeof m !== "object" || m.method !== undefined || m.id === undefined || m.id === null) return // not a response
81
+ const key = JSON.stringify(m.id)
82
+ const f = this.pending.get(key)
83
+ this.pending.delete(key)
84
+ if (f && this.hold) this.held = true
85
+ f?.(m)
86
+ }
87
+
88
+ /**
89
+ * §2.2a handoff: stop reading stdout (listeners removed, stream paused — no read left pending)
90
+ * and give the live child plus any bytes already read past the last delivered line to the
91
+ * frozen path. The transport is unusable afterwards; the child is NOT killed.
92
+ */
93
+ detach(): { child: ChildProcess; leftover: Buffer } {
94
+ this.detached = true
95
+ const out = this.child.stdout as Readable
96
+ out.off("data", this.onData)
97
+ out.off("end", this.onEnd)
98
+ out.off("close", this.onEnd)
99
+ out.pause()
100
+ this.err ??= new StdioClosedError("stdio transport detached")
101
+ const leftover = this.buf
102
+ this.buf = Buffer.alloc(0)
103
+ return { child: this.child, leftover }
104
+ }
105
+
48
106
  /** Writes one line and waits for the response with the same id. Never retried. */
49
107
  roundTrip(id: number, body: string, timeoutMs: number, signal?: AbortSignal): Promise<Obj> {
50
108
  if (this.err) return Promise.reject(this.err)
@@ -100,3 +158,70 @@ export function spawnStdio(argv: string[], env: NodeJS.ProcessEnv, cwd?: string)
100
158
  child.once("spawn", () => resolve(new StdioTransport(child)))
101
159
  })
102
160
  }
161
+
162
+ /**
163
+ * §2.2a: the frozen SDK path over a child the probe already started (StdioClientTransport always
164
+ * spawns). Takes the detached probe's stdin/stdout plus any bytes it had read but not parsed.
165
+ */
166
+ export class AdoptedStdioTransport implements SdkTransport {
167
+ onclose?: () => void
168
+ onerror?: (error: Error) => void
169
+ onmessage?: (message: JSONRPCMessage) => void
170
+ private rb = new ReadBuffer()
171
+ private closed = false
172
+
173
+ constructor(private child: ChildProcess, private leftover: Buffer) {}
174
+
175
+ async start(): Promise<void> {
176
+ const out = this.child.stdout as Readable
177
+ out.on("data", (chunk: Buffer) => this.feed(chunk))
178
+ out.on("error", (e) => this.onerror?.(e))
179
+ this.child.stdin?.on("error", (e) => this.onerror?.(e))
180
+ this.child.once("close", () => this.finish())
181
+ if (this.leftover.length) this.feed(this.leftover)
182
+ this.leftover = Buffer.alloc(0)
183
+ out.resume()
184
+ }
185
+
186
+ private feed(chunk: Buffer): void {
187
+ this.rb.append(chunk)
188
+ for (;;) {
189
+ let m: JSONRPCMessage | null
190
+ try {
191
+ m = this.rb.readMessage()
192
+ } catch (e) {
193
+ this.onerror?.(e as Error)
194
+ continue
195
+ }
196
+ if (m === null) break
197
+ this.onmessage?.(m)
198
+ }
199
+ }
200
+
201
+ send(message: JSONRPCMessage): Promise<void> {
202
+ return new Promise((resolve) => {
203
+ if (!this.child.stdin?.write(serializeMessage(message))) this.child.stdin?.once("drain", resolve)
204
+ else resolve()
205
+ })
206
+ }
207
+
208
+ private finish(): void {
209
+ if (this.closed) return
210
+ this.closed = true
211
+ this.rb.clear()
212
+ this.onclose?.()
213
+ }
214
+
215
+ /** Close stdin, then SIGKILL after 2 s if the child is still alive. */
216
+ async close(): Promise<void> {
217
+ if (this.child.exitCode === null && this.child.signalCode === null) {
218
+ const exited = new Promise<void>((r) => this.child.once("exit", () => r()))
219
+ this.child.stdin?.end()
220
+ const t = setTimeout(() => this.child.kill("SIGKILL"), 2000)
221
+ await exited
222
+ clearTimeout(t)
223
+ }
224
+ this.child.stdout?.destroy()
225
+ this.finish()
226
+ }
227
+ }
package/src/mcphandler.ts CHANGED
@@ -348,7 +348,7 @@ export function createMcpHandler(toolkitTools: () => Tool[], cfg: McpHandlerConf
348
348
  /** Validates protocol header + session (stateful). Writes the HTTP error itself on failure. */
349
349
  async function checkSession(ctx: McpContext, req: IncomingMessage, res: ServerResponse): Promise<boolean> {
350
350
  const v = header(req, "MCP-Protocol-Version")
351
- if (v !== "" && !legacy.includes(v)) {
351
+ if (v !== "" && !versions.includes(v)) {
352
352
  httpError(res, 400, "Bad Request: unsupported MCP-Protocol-Version")
353
353
  return false
354
354
  }
@@ -775,8 +775,7 @@ export function createMcpHandler(toolkitTools: () => Tool[], cfg: McpHandlerConf
775
775
  if (only2026) return true
776
776
  if (method === "initialize") return false
777
777
  const hv = header(req, "MCP-Protocol-Version")
778
- if (hv === MCP_PROTOCOL_2026) return true
779
- if (hv !== "" && versions.includes(hv)) return false
778
+ if (hv !== "" && legacy.includes(hv)) return false
780
779
  return method === "server/discover" || header(req, "Mcp-Method") !== "" || (meta !== undefined && META_VERSION in meta)
781
780
  }
782
781
 
package/src/mcpserve.ts CHANGED
@@ -16,7 +16,6 @@ import http from "node:http"
16
16
  import type { AddressInfo } from "node:net"
17
17
  import { randomBytes, timingSafeEqual } from "node:crypto"
18
18
  import { Server } from "@modelcontextprotocol/sdk/server/index.js"
19
- import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"
20
19
  import { ListToolsRequestSchema, CallToolRequestSchema, McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"
21
20
  import type { Tool, ToolSource, ToolResult } from "./types.js"
22
21
  import type { ContentPart } from "./content.js"
@@ -166,6 +165,8 @@ export interface LocalMcpServer {
166
165
  */
167
166
  export async function serveLocalMcp(tools: Tool[], cfg?: MCPServeConfig): Promise<LocalMcpServer> {
168
167
  const exposed = exposedMcpTools(tools, cfg)
168
+ // Loaded on first serve, not at import: the transport pulls in @hono/node-server.
169
+ const { StreamableHTTPServerTransport } = await import("@modelcontextprotocol/sdk/server/streamableHttp.js")
169
170
  const token = randomBytes(32).toString("hex")
170
171
  const want = Buffer.from("Bearer " + token)
171
172
  const server = http.createServer((req, res) => {
@@ -0,0 +1,143 @@
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
+
20
+ const nativeFetch = globalThis.fetch
21
+ const httpAgent = new http.Agent({ keepAlive: true })
22
+ const httpsAgent = new https.Agent({ keepAlive: true })
23
+
24
+ function proxyEnv(): boolean {
25
+ const e = process.env
26
+ return !!(e.HTTP_PROXY || e.HTTPS_PROXY || e.http_proxy || e.https_proxy || e.NODE_USE_ENV_PROXY)
27
+ }
28
+
29
+ function toHeaderRecord(h: RequestInit["headers"]): Record<string, string> {
30
+ const out: Record<string, string> = {}
31
+ if (!h) return out
32
+ if (h instanceof Headers || Array.isArray(h)) {
33
+ for (const [k, v] of h as Iterable<[string, string]>) out[k] = v
34
+ return out
35
+ }
36
+ return { ...(h as Record<string, string>) }
37
+ }
38
+
39
+ // undici's global dispatcher slot. Node fills it lazily with a default Agent on the first fetch;
40
+ // anything else there (a ProxyAgent, a mock, a tuned Agent) means the host routed fetch on purpose.
41
+ const DISPATCHER = Symbol.for("undici.globalDispatcher.1")
42
+ let knownDefaultDispatcher: unknown
43
+
44
+ function customDispatcher(): boolean {
45
+ const d = (globalThis as Record<symbol, unknown>)[DISPATCHER]
46
+ return d !== undefined && d !== knownDefaultDispatcher
47
+ }
48
+
49
+ /** Calls the platform fetch; if that call is what created Node's default dispatcher, remember it
50
+ * so later calls still take the fast path. */
51
+ function platformFetch(input: Parameters<typeof fetch>[0], init?: RequestInit): Promise<Response> {
52
+ const before = (globalThis as Record<symbol, unknown>)[DISPATCHER]
53
+ const p = globalThis.fetch(input, init)
54
+ if (before === undefined) {
55
+ const after = (globalThis as Record<symbol, unknown>)[DISPATCHER]
56
+ if (after !== undefined) knownDefaultDispatcher = after
57
+ }
58
+ return p
59
+ }
60
+
61
+ const REDIRECTS = new Set([301, 302, 303, 307, 308])
62
+
63
+ export const nodeFetch: typeof fetch = (input, init) => {
64
+ const fallback = () => platformFetch(input, init)
65
+ if (globalThis.fetch !== nativeFetch || proxyEnv() || customDispatcher()) return fallback()
66
+ if (init?.redirect && init.redirect !== "follow") return fallback()
67
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : undefined
68
+ const body = init?.body
69
+ if (url === undefined || (body != null && typeof body !== "string" && !(body instanceof Uint8Array))) return fallback()
70
+ const isHttps = url.startsWith("https:")
71
+ if (!isHttps && !url.startsWith("http:")) return fallback()
72
+ const signal = init?.signal ?? undefined
73
+ if (signal?.aborted) return Promise.reject(signal.reason)
74
+
75
+ return new Promise<Response>((resolve, reject) => {
76
+ const headers = toHeaderRecord(init?.headers)
77
+ if (body != null && !Object.keys(headers).some((k) => k.toLowerCase() === "content-length")) {
78
+ headers["content-length"] = String(typeof body === "string" ? Buffer.byteLength(body) : body.byteLength)
79
+ }
80
+ const mod = isHttps ? https : http
81
+ let settled = false
82
+ const onAbort = () => {
83
+ req.destroy(signal!.reason)
84
+ if (!settled) { settled = true; reject(signal!.reason) }
85
+ }
86
+ const req = mod.request(url, { method: init?.method ?? "GET", headers, agent: isHttps ? httpsAgent : httpAgent }, (res) => {
87
+ const status = res.statusCode ?? 0
88
+ if (REDIRECTS.has(status) && res.headers.location) {
89
+ // Follow it the way fetch does, from the redirect target: re-sending to the original URL
90
+ // would reach the server twice. 303, and 301/302 after a POST, become a bodiless GET;
91
+ // 307/308 repeat the method and body. Further hops are fetch's to follow.
92
+ res.resume()
93
+ signal?.removeEventListener("abort", onAbort)
94
+ settled = true
95
+ const target = new URL(res.headers.location, url).href
96
+ const method = (init?.method ?? "GET").toUpperCase()
97
+ const toGet = status === 303 ? method !== "HEAD" : (status === 301 || status === 302) && method === "POST"
98
+ let next: RequestInit | undefined = init
99
+ if (toGet) {
100
+ const h = toHeaderRecord(init?.headers)
101
+ for (const k of Object.keys(h)) {
102
+ if (/^content-(type|length|encoding|language|location)$/i.test(k)) delete h[k]
103
+ }
104
+ next = { ...init, method: "GET", body: undefined, headers: h }
105
+ }
106
+ platformFetch(target, next).then(resolve, reject)
107
+ return
108
+ }
109
+ const h = new Headers()
110
+ for (let i = 0; i < res.rawHeaders.length; i += 2) h.append(res.rawHeaders[i], res.rawHeaders[i + 1])
111
+ let stream: Readable = res
112
+ const enc = (res.headers["content-encoding"] ?? "").toLowerCase()
113
+ if (enc === "gzip" || enc === "x-gzip" || enc === "deflate") stream = res.pipe(zlib.createUnzip())
114
+ else if (enc === "br") stream = res.pipe(zlib.createBrotliDecompress())
115
+ const nullBody = status === 204 || status === 304 || init?.method === "HEAD"
116
+ const ctype = (res.headers["content-type"] ?? "").toLowerCase()
117
+ const done = (b: ConstructorParameters<typeof Response>[0]) => {
118
+ if (settled) return
119
+ settled = true
120
+ resolve(new Response(nullBody ? null : b, { status, statusText: res.statusMessage, headers: h }))
121
+ }
122
+ if (ctype.includes("text/event-stream")) {
123
+ // Streamed: the body is consumed incrementally (SSE). Abort still destroys the socket.
124
+ stream.once("end", () => signal?.removeEventListener("abort", onAbort))
125
+ done(Readable.toWeb(stream) as unknown as ReadableStream)
126
+ return
127
+ }
128
+ const chunks: Buffer[] = []
129
+ stream.on("data", (c: Buffer) => chunks.push(c))
130
+ stream.once("error", (e) => { if (!settled) { settled = true; reject(e) } })
131
+ stream.once("end", () => {
132
+ signal?.removeEventListener("abort", onAbort)
133
+ done(Buffer.concat(chunks))
134
+ })
135
+ })
136
+ req.once("error", (e) => {
137
+ signal?.removeEventListener("abort", onAbort)
138
+ if (!settled) { settled = true; reject(signal?.aborted ? signal.reason : new TypeError("fetch failed", { cause: e })) }
139
+ })
140
+ signal?.addEventListener("abort", onAbort, { once: true })
141
+ req.end(body ?? undefined)
142
+ })
143
+ }
package/src/serve.ts 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, type Tool } from "./types.js"
23
24
  import type { SkillInfo } from "./skill.js"
24
25
  import type { RunResult } from "./client.js"
@@ -213,6 +214,9 @@ export async function startA2AServer(opts: StartServerOptions): Promise<ServeHan
213
214
  const [host, portStr] = splitAddr(opts.addr)
214
215
  const store = a2a ? resolveStore(a2a.store) : undefined
215
216
  const exposedTools = mcp ? exposedMcpTools(mcpTools ?? [], mcp) : []
217
+ const StreamableHTTPServerTransport = mcp
218
+ ? (await import("@modelcontextprotocol/sdk/server/streamableHttp.js")).StreamableHTTPServerTransport
219
+ : undefined
216
220
 
217
221
  const server = http.createServer((req, res) => {
218
222
  const rpcRes = (id: unknown, payload: object) => {
@@ -244,7 +248,7 @@ export async function startA2AServer(opts: StartServerOptions): Promise<ServeHan
244
248
  const body = req.method === "POST" ? await readBody(req) : ""
245
249
  const parsed = body ? safeJson(body) : undefined
246
250
  const mcpServer = buildMcpServer(exposedTools, mcp, onCall)
247
- const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })
251
+ const transport = new StreamableHTTPServerTransport!({ sessionIdGenerator: undefined })
248
252
  res.on("close", () => {
249
253
  void transport.close()
250
254
  void mcpServer.close()