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/README.md +6 -2
- package/dist/acp.d.ts +16 -2
- package/dist/acp.js +74 -21
- package/dist/agents/home.d.ts +85 -4
- package/dist/agents/home.js +347 -52
- package/dist/classifier.d.ts +21 -3
- package/dist/classifier.js +175 -9
- package/dist/client.d.ts +109 -0
- package/dist/client.js +380 -75
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/mcp.js +29 -7
- package/dist/mcp2026/source.d.ts +5 -0
- package/dist/mcp2026/source.js +11 -2
- package/dist/mcp2026/stdio.d.ts +43 -0
- package/dist/mcp2026/stdio.js +144 -20
- package/dist/mcphandler.js +2 -4
- package/dist/mcpserve.js +2 -1
- package/dist/nodefetch.d.ts +1 -0
- package/dist/nodefetch.js +156 -0
- package/dist/serve.js +5 -1
- package/dist/skill.d.ts +14 -0
- package/dist/skill.js +33 -2
- package/dist/transcript_search.d.ts +27 -0
- package/dist/transcript_search.js +165 -0
- package/package.json +2 -2
- package/src/acp.ts +101 -22
- package/src/agents/home.ts +381 -53
- package/src/classifier.ts +168 -12
- package/src/client.ts +368 -57
- package/src/index.ts +1 -0
- package/src/mcp.ts +36 -7
- package/src/mcp2026/source.ts +14 -4
- package/src/mcp2026/stdio.ts +148 -23
- package/src/mcphandler.ts +2 -3
- package/src/mcpserve.ts +2 -1
- package/src/nodefetch.ts +143 -0
- package/src/serve.ts +6 -2
- package/src/skill.ts +51 -2
- package/src/transcript_search.ts +172 -0
package/src/index.ts
CHANGED
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 {
|
|
10
|
-
import {
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
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]) => {
|
package/src/mcp2026/source.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
122
|
-
|
|
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()
|
package/src/mcp2026/stdio.ts
CHANGED
|
@@ -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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
this.
|
|
38
|
-
|
|
39
|
-
|
|
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 !== "" && !
|
|
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
|
|
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) => {
|
package/src/nodefetch.ts
ADDED
|
@@ -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
|
-
|
|
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()
|