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/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/mcp.js
CHANGED
|
@@ -6,14 +6,17 @@
|
|
|
6
6
|
import { readFileSync } from "node:fs";
|
|
7
7
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
8
8
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
9
|
+
import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv";
|
|
10
|
+
import { AdoptedStdioTransport } from "./mcp2026/stdio.js";
|
|
11
11
|
import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
12
12
|
import { sanitize } from "./types.js";
|
|
13
13
|
import { summarizePart } from "./content.js";
|
|
14
|
-
import { probe, newOAuth, defaultTokenStore } from "./mcp2026/source.js";
|
|
15
14
|
import { PendingError } from "./mcp2026/auth.js";
|
|
16
15
|
export { Token, MemoryTokenStore } from "./mcp2026/auth.js";
|
|
16
|
+
// Loaded on first use, not at import: the 2026 client is only needed once a config is loaded,
|
|
17
|
+
// and the SDK's remote transports (streamable-HTTP, SSE) only when a remote server falls back.
|
|
18
|
+
let mcp2026;
|
|
19
|
+
const loadMcp2026 = () => (mcp2026 ??= import("./mcp2026/source.js"));
|
|
17
20
|
// ---------------------------------------------------------------------------
|
|
18
21
|
// MCP elicitation bridge (§10). A server can ask US for input mid-`tools/call`
|
|
19
22
|
// (a reverse-request). We map it onto the one `waitFor`: form mode → kind:"input",
|
|
@@ -396,8 +399,19 @@ function resourceTools(servers) {
|
|
|
396
399
|
* exists. Separated from connect so the caller ALWAYS holds a closeable reference — even if the
|
|
397
400
|
* connect is later abandoned by a timeout/abort, `client.close()` still terminates any spawned
|
|
398
401
|
* stdio child (no orphaned processes on cancellation). */
|
|
402
|
+
/**
|
|
403
|
+
* The SDK's default validator, built on first use instead of per Client. Building an Ajv instance
|
|
404
|
+
* (meta-schemas + formats) costs ~2-3 ms on every connect, yet it is only consulted when a tool
|
|
405
|
+
* declares an `outputSchema`. Same validator, same results — just not paid for up front.
|
|
406
|
+
*/
|
|
407
|
+
class LazyAjvValidator {
|
|
408
|
+
inner;
|
|
409
|
+
getValidator(schema) {
|
|
410
|
+
return (this.inner ??= new AjvJsonSchemaValidator()).getValidator(schema);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
399
413
|
function newMcpClient(waitFor) {
|
|
400
|
-
const client = new Client({ name: "toolnexus", version: "0.1.0" }, { capabilities: waitFor ? { elicitation: {} } : {} });
|
|
414
|
+
const client = new Client({ name: "toolnexus", version: "0.1.0" }, { capabilities: waitFor ? { elicitation: {} } : {}, jsonSchemaValidator: new LazyAjvValidator() });
|
|
401
415
|
if (waitFor) {
|
|
402
416
|
client.setRequestHandler(ElicitRequestSchema, async (request) => answerToElicitResult(await waitFor(elicitationToRequest(request.params))));
|
|
403
417
|
}
|
|
@@ -410,6 +424,10 @@ async function connectTransport(client, cfg, auth) {
|
|
|
410
424
|
const expanded = expandEnvHeaders(cfg.headers);
|
|
411
425
|
const requestInit = expanded ? { headers: expanded } : undefined;
|
|
412
426
|
const fetch = auth ? bearerFetch(cfg.url, auth) : undefined;
|
|
427
|
+
const [{ StreamableHTTPClientTransport }, { SSEClientTransport }] = await Promise.all([
|
|
428
|
+
import("@modelcontextprotocol/sdk/client/streamableHttp.js"),
|
|
429
|
+
import("@modelcontextprotocol/sdk/client/sse.js"),
|
|
430
|
+
]);
|
|
413
431
|
try {
|
|
414
432
|
await client.connect(new StreamableHTTPClientTransport(url, { requestInit, ...(fetch ? { fetch } : {}) }));
|
|
415
433
|
}
|
|
@@ -469,6 +487,7 @@ function bearerFetch(resource, a) {
|
|
|
469
487
|
* path, with the OAuth bearer attached for remote servers. Throws ⇒ the server failed.
|
|
470
488
|
*/
|
|
471
489
|
async function connectServer(name, cfg, timeout, opts, store, sdkClient, onSdk) {
|
|
490
|
+
const { newOAuth, probe } = await loadMcp2026();
|
|
472
491
|
const o = { waitFor: opts.waitFor, tokenStore: store, openUrl: opts.openUrl, signal: opts.signal, disableTools: opts.disableTools };
|
|
473
492
|
const remote = isRemote(cfg);
|
|
474
493
|
const oauth = remote ? newOAuth(cfg.auth, o, store) : undefined;
|
|
@@ -485,7 +504,10 @@ async function connectServer(name, cfg, timeout, opts, store, sdkClient, onSdk)
|
|
|
485
504
|
return p;
|
|
486
505
|
const client = sdkClient();
|
|
487
506
|
onSdk(client);
|
|
488
|
-
|
|
507
|
+
const connecting = p.reuse
|
|
508
|
+
? client.connect(new AdoptedStdioTransport(p.reuse.child, p.reuse.leftover)) // §2.2a: same child, no respawn
|
|
509
|
+
: connectTransport(client, cfg, oauth ? { oauth, waitFor: opts.waitFor } : undefined);
|
|
510
|
+
await raceTimeout(connecting, timeout, opts.signal);
|
|
489
511
|
const defs = await raceTimeout(paginateTools(client, timeout), timeout, opts.signal);
|
|
490
512
|
return { kind: "sdk", client, defs };
|
|
491
513
|
}
|
|
@@ -499,7 +521,7 @@ export async function loadMcp(input, opts) {
|
|
|
499
521
|
const clients = [];
|
|
500
522
|
const servers = new Map();
|
|
501
523
|
const skills = [];
|
|
502
|
-
const store = defaultTokenStore({ tokenStore: opts?.tokenStore });
|
|
524
|
+
const store = (await loadMcp2026()).defaultTokenStore({ tokenStore: opts?.tokenStore });
|
|
503
525
|
const waitFor = opts?.waitFor;
|
|
504
526
|
const signal = opts?.signal;
|
|
505
527
|
const onServer = opts?.onServer;
|
|
@@ -590,7 +612,7 @@ export async function listMcpTools(input, opts) {
|
|
|
590
612
|
const toolsByServer = {};
|
|
591
613
|
const status = {};
|
|
592
614
|
const signal = opts?.signal;
|
|
593
|
-
const store = defaultTokenStore({ tokenStore: opts?.tokenStore });
|
|
615
|
+
const store = (await loadMcp2026()).defaultTokenStore({ tokenStore: opts?.tokenStore });
|
|
594
616
|
await Promise.all(Object.entries(config).map(async ([name, cfg]) => {
|
|
595
617
|
if (!isEnabled(cfg)) {
|
|
596
618
|
status[name] = "disabled";
|
package/dist/mcp2026/source.d.ts
CHANGED
|
@@ -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;
|
package/dist/mcp2026/source.js
CHANGED
|
@@ -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
|
-
|
|
86
|
-
|
|
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();
|
package/dist/mcp2026/stdio.d.ts
CHANGED
|
@@ -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
|
+
}
|
package/dist/mcp2026/stdio.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/mcphandler.js
CHANGED
|
@@ -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 !== "" && !
|
|
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
|
|
659
|
-
return true;
|
|
660
|
-
if (hv !== "" && versions.includes(hv))
|
|
658
|
+
if (hv !== "" && legacy.includes(hv))
|
|
661
659
|
return false;
|
|
662
660
|
return method === "server/discover" || header(req, "Mcp-Method") !== "" || (meta !== undefined && META_VERSION in meta);
|
|
663
661
|
}
|
package/dist/mcpserve.js
CHANGED
|
@@ -15,7 +15,6 @@
|
|
|
15
15
|
import http from "node:http";
|
|
16
16
|
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
17
17
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
18
|
-
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
19
18
|
import { ListToolsRequestSchema, CallToolRequestSchema, McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
|
|
20
19
|
const DEFAULT_NAME = "toolnexus";
|
|
21
20
|
const DEFAULT_VERSION = "0.1.0";
|
|
@@ -133,6 +132,8 @@ function partBlocks(parts) {
|
|
|
133
132
|
*/
|
|
134
133
|
export async function serveLocalMcp(tools, cfg) {
|
|
135
134
|
const exposed = exposedMcpTools(tools, cfg);
|
|
135
|
+
// Loaded on first serve, not at import: the transport pulls in @hono/node-server.
|
|
136
|
+
const { StreamableHTTPServerTransport } = await import("@modelcontextprotocol/sdk/server/streamableHttp.js");
|
|
136
137
|
const token = randomBytes(32).toString("hex");
|
|
137
138
|
const want = Buffer.from("Bearer " + token);
|
|
138
139
|
const server = http.createServer((req, res) => {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const nodeFetch: typeof fetch;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default LLM transport: a `fetch`-shaped function over `node:http`/`node:https` with a shared
|
|
3
|
+
* keep-alive agent.
|
|
4
|
+
*
|
|
5
|
+
* Why: Node's built-in `fetch` (undici + web streams) costs ~1.5 ms per local round-trip, against
|
|
6
|
+
* ~0.3 ms for `node:http` on the same keep-alive socket. With two LLM calls per tool-calling turn
|
|
7
|
+
* that was most of the client's per-request overhead (benchmarks/run_toolnexus_js.mjs).
|
|
8
|
+
*
|
|
9
|
+
* Behavior is kept identical to `fetch` for what the client uses (status, headers incl.
|
|
10
|
+
* Retry-After, json/text, a streamed `body` for SSE, abort via `signal` rejecting with its reason).
|
|
11
|
+
* Anything this path does not cover is handed to `globalThis.fetch` unchanged: a proxy env
|
|
12
|
+
* (`HTTP(S)_PROXY`, `NODE_USE_ENV_PROXY`), a replaced global `fetch`, a non-http(s) URL, a body
|
|
13
|
+
* that is not a string/bytes, and redirects.
|
|
14
|
+
*/
|
|
15
|
+
import http from "node:http";
|
|
16
|
+
import https from "node:https";
|
|
17
|
+
import { Readable } from "node:stream";
|
|
18
|
+
import zlib from "node:zlib";
|
|
19
|
+
const nativeFetch = globalThis.fetch;
|
|
20
|
+
const httpAgent = new http.Agent({ keepAlive: true });
|
|
21
|
+
const httpsAgent = new https.Agent({ keepAlive: true });
|
|
22
|
+
function proxyEnv() {
|
|
23
|
+
const e = process.env;
|
|
24
|
+
return !!(e.HTTP_PROXY || e.HTTPS_PROXY || e.http_proxy || e.https_proxy || e.NODE_USE_ENV_PROXY);
|
|
25
|
+
}
|
|
26
|
+
function toHeaderRecord(h) {
|
|
27
|
+
const out = {};
|
|
28
|
+
if (!h)
|
|
29
|
+
return out;
|
|
30
|
+
if (h instanceof Headers || Array.isArray(h)) {
|
|
31
|
+
for (const [k, v] of h)
|
|
32
|
+
out[k] = v;
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
return { ...h };
|
|
36
|
+
}
|
|
37
|
+
// undici's global dispatcher slot. Node fills it lazily with a default Agent on the first fetch;
|
|
38
|
+
// anything else there (a ProxyAgent, a mock, a tuned Agent) means the host routed fetch on purpose.
|
|
39
|
+
const DISPATCHER = Symbol.for("undici.globalDispatcher.1");
|
|
40
|
+
let knownDefaultDispatcher;
|
|
41
|
+
function customDispatcher() {
|
|
42
|
+
const d = globalThis[DISPATCHER];
|
|
43
|
+
return d !== undefined && d !== knownDefaultDispatcher;
|
|
44
|
+
}
|
|
45
|
+
/** Calls the platform fetch; if that call is what created Node's default dispatcher, remember it
|
|
46
|
+
* so later calls still take the fast path. */
|
|
47
|
+
function platformFetch(input, init) {
|
|
48
|
+
const before = globalThis[DISPATCHER];
|
|
49
|
+
const p = globalThis.fetch(input, init);
|
|
50
|
+
if (before === undefined) {
|
|
51
|
+
const after = globalThis[DISPATCHER];
|
|
52
|
+
if (after !== undefined)
|
|
53
|
+
knownDefaultDispatcher = after;
|
|
54
|
+
}
|
|
55
|
+
return p;
|
|
56
|
+
}
|
|
57
|
+
const REDIRECTS = new Set([301, 302, 303, 307, 308]);
|
|
58
|
+
export const nodeFetch = (input, init) => {
|
|
59
|
+
const fallback = () => platformFetch(input, init);
|
|
60
|
+
if (globalThis.fetch !== nativeFetch || proxyEnv() || customDispatcher())
|
|
61
|
+
return fallback();
|
|
62
|
+
if (init?.redirect && init.redirect !== "follow")
|
|
63
|
+
return fallback();
|
|
64
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.href : undefined;
|
|
65
|
+
const body = init?.body;
|
|
66
|
+
if (url === undefined || (body != null && typeof body !== "string" && !(body instanceof Uint8Array)))
|
|
67
|
+
return fallback();
|
|
68
|
+
const isHttps = url.startsWith("https:");
|
|
69
|
+
if (!isHttps && !url.startsWith("http:"))
|
|
70
|
+
return fallback();
|
|
71
|
+
const signal = init?.signal ?? undefined;
|
|
72
|
+
if (signal?.aborted)
|
|
73
|
+
return Promise.reject(signal.reason);
|
|
74
|
+
return new Promise((resolve, reject) => {
|
|
75
|
+
const headers = toHeaderRecord(init?.headers);
|
|
76
|
+
if (body != null && !Object.keys(headers).some((k) => k.toLowerCase() === "content-length")) {
|
|
77
|
+
headers["content-length"] = String(typeof body === "string" ? Buffer.byteLength(body) : body.byteLength);
|
|
78
|
+
}
|
|
79
|
+
const mod = isHttps ? https : http;
|
|
80
|
+
let settled = false;
|
|
81
|
+
const onAbort = () => {
|
|
82
|
+
req.destroy(signal.reason);
|
|
83
|
+
if (!settled) {
|
|
84
|
+
settled = true;
|
|
85
|
+
reject(signal.reason);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
const req = mod.request(url, { method: init?.method ?? "GET", headers, agent: isHttps ? httpsAgent : httpAgent }, (res) => {
|
|
89
|
+
const status = res.statusCode ?? 0;
|
|
90
|
+
if (REDIRECTS.has(status) && res.headers.location) {
|
|
91
|
+
// Follow it the way fetch does, from the redirect target: re-sending to the original URL
|
|
92
|
+
// would reach the server twice. 303, and 301/302 after a POST, become a bodiless GET;
|
|
93
|
+
// 307/308 repeat the method and body. Further hops are fetch's to follow.
|
|
94
|
+
res.resume();
|
|
95
|
+
signal?.removeEventListener("abort", onAbort);
|
|
96
|
+
settled = true;
|
|
97
|
+
const target = new URL(res.headers.location, url).href;
|
|
98
|
+
const method = (init?.method ?? "GET").toUpperCase();
|
|
99
|
+
const toGet = status === 303 ? method !== "HEAD" : (status === 301 || status === 302) && method === "POST";
|
|
100
|
+
let next = init;
|
|
101
|
+
if (toGet) {
|
|
102
|
+
const h = toHeaderRecord(init?.headers);
|
|
103
|
+
for (const k of Object.keys(h)) {
|
|
104
|
+
if (/^content-(type|length|encoding|language|location)$/i.test(k))
|
|
105
|
+
delete h[k];
|
|
106
|
+
}
|
|
107
|
+
next = { ...init, method: "GET", body: undefined, headers: h };
|
|
108
|
+
}
|
|
109
|
+
platformFetch(target, next).then(resolve, reject);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const h = new Headers();
|
|
113
|
+
for (let i = 0; i < res.rawHeaders.length; i += 2)
|
|
114
|
+
h.append(res.rawHeaders[i], res.rawHeaders[i + 1]);
|
|
115
|
+
let stream = res;
|
|
116
|
+
const enc = (res.headers["content-encoding"] ?? "").toLowerCase();
|
|
117
|
+
if (enc === "gzip" || enc === "x-gzip" || enc === "deflate")
|
|
118
|
+
stream = res.pipe(zlib.createUnzip());
|
|
119
|
+
else if (enc === "br")
|
|
120
|
+
stream = res.pipe(zlib.createBrotliDecompress());
|
|
121
|
+
const nullBody = status === 204 || status === 304 || init?.method === "HEAD";
|
|
122
|
+
const ctype = (res.headers["content-type"] ?? "").toLowerCase();
|
|
123
|
+
const done = (b) => {
|
|
124
|
+
if (settled)
|
|
125
|
+
return;
|
|
126
|
+
settled = true;
|
|
127
|
+
resolve(new Response(nullBody ? null : b, { status, statusText: res.statusMessage, headers: h }));
|
|
128
|
+
};
|
|
129
|
+
if (ctype.includes("text/event-stream")) {
|
|
130
|
+
// Streamed: the body is consumed incrementally (SSE). Abort still destroys the socket.
|
|
131
|
+
stream.once("end", () => signal?.removeEventListener("abort", onAbort));
|
|
132
|
+
done(Readable.toWeb(stream));
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const chunks = [];
|
|
136
|
+
stream.on("data", (c) => chunks.push(c));
|
|
137
|
+
stream.once("error", (e) => { if (!settled) {
|
|
138
|
+
settled = true;
|
|
139
|
+
reject(e);
|
|
140
|
+
} });
|
|
141
|
+
stream.once("end", () => {
|
|
142
|
+
signal?.removeEventListener("abort", onAbort);
|
|
143
|
+
done(Buffer.concat(chunks));
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
req.once("error", (e) => {
|
|
147
|
+
signal?.removeEventListener("abort", onAbort);
|
|
148
|
+
if (!settled) {
|
|
149
|
+
settled = true;
|
|
150
|
+
reject(signal?.aborted ? signal.reason : new TypeError("fetch failed", { cause: e }));
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
154
|
+
req.end(body ?? undefined);
|
|
155
|
+
});
|
|
156
|
+
};
|
package/dist/serve.js
CHANGED
|
@@ -18,7 +18,8 @@ import http from "node:http";
|
|
|
18
18
|
import fs from "node:fs";
|
|
19
19
|
import path from "node:path";
|
|
20
20
|
import { randomUUID } from "node:crypto";
|
|
21
|
-
|
|
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
|