toolnexus 0.25.0 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -0
- package/dist/acp.d.ts +10 -2
- package/dist/acp.js +36 -5
- package/dist/classifier.d.ts +199 -31
- package/dist/classifier.js +639 -31
- package/dist/client.d.ts +6 -0
- package/dist/judge.d.ts +6 -3
- package/dist/judge.js +13 -1
- package/dist/mcp.js +16 -6
- 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/serve.js +12 -1
- package/dist/skill.d.ts +14 -0
- package/dist/skill.js +33 -2
- package/package.json +2 -2
- package/src/acp.ts +59 -7
- package/src/classifier.ts +723 -47
- package/src/client.ts +6 -0
- package/src/judge.ts +19 -4
- package/src/mcp.ts +18 -6
- 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/serve.ts +13 -2
- package/src/skill.ts +51 -2
package/dist/client.d.ts
CHANGED
|
@@ -208,6 +208,12 @@ export type MetricEvent = {
|
|
|
208
208
|
promptTokens: number;
|
|
209
209
|
completionTokens: number;
|
|
210
210
|
error?: string;
|
|
211
|
+
/** L1: how many questions were asked. */
|
|
212
|
+
questions: number;
|
|
213
|
+
/** L1: sha256 hex of `canonicalRequest(model, questions)` ("" when unencodable). */
|
|
214
|
+
questionsHash: string;
|
|
215
|
+
/** True when the decision came from the classifier cache and no request was sent. */
|
|
216
|
+
cached: boolean;
|
|
211
217
|
} | {
|
|
212
218
|
event: "classifier.warning";
|
|
213
219
|
question: string;
|
package/dist/judge.d.ts
CHANGED
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
*
|
|
8
8
|
* A gate never authorises; it only declines to decide.
|
|
9
9
|
*/
|
|
10
|
-
import { Classifier, Decision, type DecisionAnswer, type NoulCriteria, type
|
|
10
|
+
import { Classifier, Decision, type DecisionAnswer, type NoulCriteria, type AnyQuestion, type RecordedDecision } from "./classifier.js";
|
|
11
11
|
import type { Request } from "./types.js";
|
|
12
12
|
/** One named question of an ordered question list. */
|
|
13
13
|
export interface NamedQuestion {
|
|
14
14
|
name: string;
|
|
15
|
-
question:
|
|
15
|
+
question: AnyQuestion;
|
|
16
16
|
}
|
|
17
17
|
/** `noul` question with a name. Name the state field it judges in `instructions`. */
|
|
18
18
|
export declare function noul(name: string, instructions: string, criteria?: NoulCriteria): NamedQuestion;
|
|
@@ -20,8 +20,11 @@ export declare function noul(name: string, instructions: string, criteria?: Noul
|
|
|
20
20
|
export declare function choice(name: string, instructions: string, options: Record<string, string>): NamedQuestion;
|
|
21
21
|
/** `score` question with a name; `levels` is the ordered rubric. */
|
|
22
22
|
export declare function score(name: string, instructions: string, levels: string[]): NamedQuestion;
|
|
23
|
+
/** A raw question with a name: a System One type the library does not model (e.g. `box`), sent
|
|
24
|
+
* as written. Its answer decodes to a raw answer `{type, value}`. */
|
|
25
|
+
export declare function raw(name: string, type: string, instructions: string, criteria?: unknown): NamedQuestion;
|
|
23
26
|
/** Ordered list -> the §8B questions map. A duplicate name is an error naming it. */
|
|
24
|
-
export declare function questionMap(questions: readonly NamedQuestion[]): Record<string,
|
|
27
|
+
export declare function questionMap(questions: readonly NamedQuestion[]): Record<string, AnyQuestion>;
|
|
25
28
|
/** `role` next to the data's fields; a non-object value goes under `data`. */
|
|
26
29
|
export declare function State(role: string, data: unknown): Record<string, unknown>;
|
|
27
30
|
/** Sugar: `{context, message, ...extra}`. */
|
package/dist/judge.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*
|
|
8
8
|
* A gate never authorises; it only declines to decide.
|
|
9
9
|
*/
|
|
10
|
-
import { Classifier, noul as noulQ, choice as choiceQ, score as scoreQ, } from "./classifier.js";
|
|
10
|
+
import { Classifier, isRawAnswer, raw as rawQ, noul as noulQ, choice as choiceQ, score as scoreQ, } from "./classifier.js";
|
|
11
11
|
/** `noul` question with a name. Name the state field it judges in `instructions`. */
|
|
12
12
|
export function noul(name, instructions, criteria) {
|
|
13
13
|
return { name, question: noulQ(instructions, criteria) };
|
|
@@ -20,6 +20,11 @@ export function choice(name, instructions, options) {
|
|
|
20
20
|
export function score(name, instructions, levels) {
|
|
21
21
|
return { name, question: scoreQ(instructions, levels) };
|
|
22
22
|
}
|
|
23
|
+
/** A raw question with a name: a System One type the library does not model (e.g. `box`), sent
|
|
24
|
+
* as written. Its answer decodes to a raw answer `{type, value}`. */
|
|
25
|
+
export function raw(name, type, instructions, criteria) {
|
|
26
|
+
return { name, question: rawQ(type, instructions, criteria) };
|
|
27
|
+
}
|
|
23
28
|
/** Ordered list -> the §8B questions map. A duplicate name is an error naming it. */
|
|
24
29
|
export function questionMap(questions) {
|
|
25
30
|
const out = {};
|
|
@@ -47,6 +52,13 @@ function noulBand(p, b) {
|
|
|
47
52
|
return p < b.low ? "no" : p > b.high ? "yes" : "uncertain";
|
|
48
53
|
}
|
|
49
54
|
function wrap(a, b) {
|
|
55
|
+
if (isRawAnswer(a)) {
|
|
56
|
+
// An unmodelled answer type is never sure: there is no number to band.
|
|
57
|
+
const out = { ...a, sure: false };
|
|
58
|
+
Object.defineProperty(out, "value", { value: () => 0, enumerable: false });
|
|
59
|
+
Object.defineProperty(out, "pick", { value: () => "", enumerable: false });
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
50
62
|
const extra = a.type === "noul"
|
|
51
63
|
? { band: noulBand(a.noul, b) }
|
|
52
64
|
: { sure: a.confidence > b.high && !(a.type === "choice" && a.nearUniform) };
|
package/dist/mcp.js
CHANGED
|
@@ -6,14 +6,16 @@
|
|
|
6
6
|
import { readFileSync } from "node:fs";
|
|
7
7
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
8
8
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
9
|
-
import {
|
|
10
|
-
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
9
|
+
import { AdoptedStdioTransport } from "./mcp2026/stdio.js";
|
|
11
10
|
import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
12
11
|
import { sanitize } from "./types.js";
|
|
13
12
|
import { summarizePart } from "./content.js";
|
|
14
|
-
import { probe, newOAuth, defaultTokenStore } from "./mcp2026/source.js";
|
|
15
13
|
import { PendingError } from "./mcp2026/auth.js";
|
|
16
14
|
export { Token, MemoryTokenStore } from "./mcp2026/auth.js";
|
|
15
|
+
// Loaded on first use, not at import: the 2026 client is only needed once a config is loaded,
|
|
16
|
+
// and the SDK's remote transports (streamable-HTTP, SSE) only when a remote server falls back.
|
|
17
|
+
let mcp2026;
|
|
18
|
+
const loadMcp2026 = () => (mcp2026 ??= import("./mcp2026/source.js"));
|
|
17
19
|
// ---------------------------------------------------------------------------
|
|
18
20
|
// MCP elicitation bridge (§10). A server can ask US for input mid-`tools/call`
|
|
19
21
|
// (a reverse-request). We map it onto the one `waitFor`: form mode → kind:"input",
|
|
@@ -410,6 +412,10 @@ async function connectTransport(client, cfg, auth) {
|
|
|
410
412
|
const expanded = expandEnvHeaders(cfg.headers);
|
|
411
413
|
const requestInit = expanded ? { headers: expanded } : undefined;
|
|
412
414
|
const fetch = auth ? bearerFetch(cfg.url, auth) : undefined;
|
|
415
|
+
const [{ StreamableHTTPClientTransport }, { SSEClientTransport }] = await Promise.all([
|
|
416
|
+
import("@modelcontextprotocol/sdk/client/streamableHttp.js"),
|
|
417
|
+
import("@modelcontextprotocol/sdk/client/sse.js"),
|
|
418
|
+
]);
|
|
413
419
|
try {
|
|
414
420
|
await client.connect(new StreamableHTTPClientTransport(url, { requestInit, ...(fetch ? { fetch } : {}) }));
|
|
415
421
|
}
|
|
@@ -469,6 +475,7 @@ function bearerFetch(resource, a) {
|
|
|
469
475
|
* path, with the OAuth bearer attached for remote servers. Throws ⇒ the server failed.
|
|
470
476
|
*/
|
|
471
477
|
async function connectServer(name, cfg, timeout, opts, store, sdkClient, onSdk) {
|
|
478
|
+
const { newOAuth, probe } = await loadMcp2026();
|
|
472
479
|
const o = { waitFor: opts.waitFor, tokenStore: store, openUrl: opts.openUrl, signal: opts.signal, disableTools: opts.disableTools };
|
|
473
480
|
const remote = isRemote(cfg);
|
|
474
481
|
const oauth = remote ? newOAuth(cfg.auth, o, store) : undefined;
|
|
@@ -485,7 +492,10 @@ async function connectServer(name, cfg, timeout, opts, store, sdkClient, onSdk)
|
|
|
485
492
|
return p;
|
|
486
493
|
const client = sdkClient();
|
|
487
494
|
onSdk(client);
|
|
488
|
-
|
|
495
|
+
const connecting = p.reuse
|
|
496
|
+
? client.connect(new AdoptedStdioTransport(p.reuse.child, p.reuse.leftover)) // §2.2a: same child, no respawn
|
|
497
|
+
: connectTransport(client, cfg, oauth ? { oauth, waitFor: opts.waitFor } : undefined);
|
|
498
|
+
await raceTimeout(connecting, timeout, opts.signal);
|
|
489
499
|
const defs = await raceTimeout(paginateTools(client, timeout), timeout, opts.signal);
|
|
490
500
|
return { kind: "sdk", client, defs };
|
|
491
501
|
}
|
|
@@ -499,7 +509,7 @@ export async function loadMcp(input, opts) {
|
|
|
499
509
|
const clients = [];
|
|
500
510
|
const servers = new Map();
|
|
501
511
|
const skills = [];
|
|
502
|
-
const store = defaultTokenStore({ tokenStore: opts?.tokenStore });
|
|
512
|
+
const store = (await loadMcp2026()).defaultTokenStore({ tokenStore: opts?.tokenStore });
|
|
503
513
|
const waitFor = opts?.waitFor;
|
|
504
514
|
const signal = opts?.signal;
|
|
505
515
|
const onServer = opts?.onServer;
|
|
@@ -590,7 +600,7 @@ export async function listMcpTools(input, opts) {
|
|
|
590
600
|
const toolsByServer = {};
|
|
591
601
|
const status = {};
|
|
592
602
|
const signal = opts?.signal;
|
|
593
|
-
const store = defaultTokenStore({ tokenStore: opts?.tokenStore });
|
|
603
|
+
const store = (await loadMcp2026()).defaultTokenStore({ tokenStore: opts?.tokenStore });
|
|
594
604
|
await Promise.all(Object.entries(config).map(async ([name, cfg]) => {
|
|
595
605
|
if (!isEnabled(cfg)) {
|
|
596
606
|
status[name] = "disabled";
|
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) => {
|
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" });
|
|
@@ -153,6 +157,13 @@ export async function startA2AServer(opts) {
|
|
|
153
157
|
// per request (stateless — sessionIdGenerator undefined). Checked first so
|
|
154
158
|
// `/mcp` is never shadowed by the A2A POST handler.
|
|
155
159
|
if (mcp && req.url && (req.url === "/mcp" || req.url.startsWith("/mcp?"))) {
|
|
160
|
+
// Stateless profile: no server-initiated SSE stream exists, so only POST is
|
|
161
|
+
// served. Refuse anything else at once (§7C) instead of holding a GET open.
|
|
162
|
+
if (req.method !== "POST") {
|
|
163
|
+
res.writeHead(405, { Allow: "POST" });
|
|
164
|
+
res.end();
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
156
167
|
void (async () => {
|
|
157
168
|
const body = req.method === "POST" ? await readBody(req) : "";
|
|
158
169
|
const parsed = body ? safeJson(body) : undefined;
|
package/dist/skill.d.ts
CHANGED
|
@@ -39,10 +39,22 @@ export interface SkillSkip {
|
|
|
39
39
|
*/
|
|
40
40
|
detail?: string;
|
|
41
41
|
}
|
|
42
|
+
/** Why a KEPT skill carries a warning (SPEC.md §3, #84). The skill still loads unchanged. */
|
|
43
|
+
export type SkillWarningReason = "oversized-description";
|
|
44
|
+
/** Maximum skill `description` length, in Unicode code points, before a warning (SPEC.md §3). */
|
|
45
|
+
export declare const SKILL_DESCRIPTION_MAX_CHARS = 1024;
|
|
46
|
+
export interface SkillWarning {
|
|
47
|
+
location: string;
|
|
48
|
+
reason: SkillWarningReason;
|
|
49
|
+
/** Description length in Unicode CODE POINTS (not bytes, not UTF-16 units). */
|
|
50
|
+
chars: number;
|
|
51
|
+
}
|
|
42
52
|
/** Result of a list-only validate pass (SPEC.md §3, S3). */
|
|
43
53
|
export interface SkillInventory {
|
|
44
54
|
skills: SkillInfo[];
|
|
45
55
|
skipped: SkillSkip[];
|
|
56
|
+
/** Kept skills that load but carry a warning (oversized description), in discovery order. */
|
|
57
|
+
warnings: SkillWarning[];
|
|
46
58
|
}
|
|
47
59
|
/** Options for loadSkills / listSkills (SPEC.md §3, S1/S2/S5). */
|
|
48
60
|
export interface LoadSkillsOptions {
|
|
@@ -65,6 +77,8 @@ export interface SkillSource {
|
|
|
65
77
|
* that stops being reported is indistinguishable from something that was finished.
|
|
66
78
|
*/
|
|
67
79
|
skipped: SkillSkip[];
|
|
80
|
+
/** Exposed (post-allowlist) skills whose description is oversized; never modified (#84). */
|
|
81
|
+
warnings: SkillWarning[];
|
|
68
82
|
}
|
|
69
83
|
/**
|
|
70
84
|
* Discover + validate skills from the same sources loadSkills accepts, returning
|
package/dist/skill.js
CHANGED
|
@@ -12,8 +12,16 @@
|
|
|
12
12
|
import { readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
13
13
|
import path from "node:path";
|
|
14
14
|
import { pathToFileURL } from "node:url";
|
|
15
|
-
import {
|
|
15
|
+
import { createRequire } from "node:module";
|
|
16
16
|
import { compareCodePoints, compareDiscovery, sortEntriesByName } from "./order.js";
|
|
17
|
+
// yaml is required on the first frontmatter parse, not at import, so a host with no skills never
|
|
18
|
+
// loads it. `require` keeps the parse synchronous (listSkills/loadSkills are sync); yaml's Node
|
|
19
|
+
// entry is CommonJS, so this is the same module instance an `import` would get.
|
|
20
|
+
let yamlParse;
|
|
21
|
+
function parseYaml(src) {
|
|
22
|
+
yamlParse ??= createRequire(import.meta.url)("yaml").parse;
|
|
23
|
+
return yamlParse(src);
|
|
24
|
+
}
|
|
17
25
|
export const SKILL_TOOL_DESCRIPTION = `Load a specialized skill when the task at hand matches one of the skills listed in the system prompt.
|
|
18
26
|
|
|
19
27
|
Use this tool to inject the skill's instructions and resources into current conversation. The output may contain detailed workflow guidance as well as references to scripts, files, etc in the same directory as the skill.
|
|
@@ -25,6 +33,8 @@ The skill name must match one of the skills listed in your system prompt.`;
|
|
|
25
33
|
*/
|
|
26
34
|
export const SKILLS_PROMPT_PREAMBLE = "Skills provide specialized instructions and workflows for specific tasks.\n" +
|
|
27
35
|
"Use the skill tool to load a skill when a task matches its description.";
|
|
36
|
+
/** Maximum skill `description` length, in Unicode code points, before a warning (SPEC.md §3). */
|
|
37
|
+
export const SKILL_DESCRIPTION_MAX_CHARS = 1024;
|
|
28
38
|
/**
|
|
29
39
|
* Parse YAML frontmatter (between the leading `---` fences) with a real YAML parser, so folded
|
|
30
40
|
* (`>`)/literal (`|`) block scalars, quoting and multi-line values all resolve correctly.
|
|
@@ -304,6 +314,24 @@ function collectCandidates(opts) {
|
|
|
304
314
|
cands.push(...candidatesFromDefs(opts.skills));
|
|
305
315
|
return cands;
|
|
306
316
|
}
|
|
317
|
+
/**
|
|
318
|
+
* Warn — never modify — skills whose description exceeds SKILL_DESCRIPTION_MAX_CHARS code
|
|
319
|
+
* points (SPEC.md §3, #84). Each warning is also sent to the same channel as the
|
|
320
|
+
* duplicate-name warning. Order follows the given skills (discovery order).
|
|
321
|
+
*/
|
|
322
|
+
function describeWarnings(skills) {
|
|
323
|
+
const out = [];
|
|
324
|
+
for (const s of skills) {
|
|
325
|
+
if (s.description === undefined)
|
|
326
|
+
continue;
|
|
327
|
+
const chars = [...s.description].length;
|
|
328
|
+
if (chars > SKILL_DESCRIPTION_MAX_CHARS) {
|
|
329
|
+
console.warn(`[toolnexus] skill "${s.name}" (${s.location}) description is ${chars} chars, over ${SKILL_DESCRIPTION_MAX_CHARS} — kept unmodified`);
|
|
330
|
+
out.push({ location: s.location, reason: "oversized-description", chars });
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return out;
|
|
334
|
+
}
|
|
307
335
|
/** Dedupe candidates by name (first-wins); later duplicates become skips. */
|
|
308
336
|
function mergeCandidates(cands) {
|
|
309
337
|
const skills = {};
|
|
@@ -355,7 +383,8 @@ function applySkillsFilter(skills, filter) {
|
|
|
355
383
|
export function listSkills(input) {
|
|
356
384
|
const opts = typeof input === "string" || Array.isArray(input) ? { dirs: input } : input;
|
|
357
385
|
const merged = mergeCandidates(collectCandidates(opts));
|
|
358
|
-
|
|
386
|
+
const list = Object.values(merged.skills);
|
|
387
|
+
return { skills: list, skipped: merged.skipped, warnings: describeWarnings(list) };
|
|
359
388
|
}
|
|
360
389
|
/** Discover skills (dirs and/or data) and build the `skill` loader tool. */
|
|
361
390
|
export function loadSkills(input) {
|
|
@@ -364,6 +393,7 @@ export function loadSkills(input) {
|
|
|
364
393
|
const merged = mergeCandidates(collectCandidates(opts));
|
|
365
394
|
const skills = applySkillsFilter(merged.skills, opts.filter);
|
|
366
395
|
const skipped = merged.skipped;
|
|
396
|
+
const warnings = describeWarnings(Object.values(skills));
|
|
367
397
|
const tool = {
|
|
368
398
|
name: "skill",
|
|
369
399
|
description: SKILL_TOOL_DESCRIPTION,
|
|
@@ -426,6 +456,7 @@ export function loadSkills(input) {
|
|
|
426
456
|
skills,
|
|
427
457
|
tool,
|
|
428
458
|
skipped,
|
|
459
|
+
warnings,
|
|
429
460
|
prompt() {
|
|
430
461
|
const described = Object.values(skills)
|
|
431
462
|
.filter((s) => s.description !== undefined)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolnexus",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
4
4
|
"description": "Dynamic MCP servers + agent skills as uniform tools for any LLM (opencode-style).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
],
|
|
18
18
|
"scripts": {
|
|
19
19
|
"build": "tsc -p tsconfig.json",
|
|
20
|
-
"test": "npm run build && node --experimental-strip-types --test test/unit.test.ts test/agents.test.ts test/agent-hooks.test.ts test/agent-inprocess.test.ts test/home.test.ts test/compaction.test.ts test/loop.test.ts test/in-process.test.ts test/translate.test.ts test/multimodal.test.ts test/classifier.test.ts test/acp.test.ts test/acp-client.test.ts test/issues.test.ts test/host-boundary.test.ts test/judge.test.ts test/batteries.test.ts test/host-engine.test.ts test/host-turn-durability.test.ts test/host-engine-asks.test.ts test/tool-concurrency.test.ts test/media.test.ts test/examples.test.ts test/mcphandler.test.ts test/mcphandler-conformance.test.ts test/mcp2026.test.ts",
|
|
20
|
+
"test": "npm run build && node --experimental-strip-types --test test/unit.test.ts test/agents.test.ts test/agent-hooks.test.ts test/agent-inprocess.test.ts test/home.test.ts test/compaction.test.ts test/loop.test.ts test/in-process.test.ts test/translate.test.ts test/multimodal.test.ts test/classifier.test.ts test/classifier-backends.test.ts test/acp.test.ts test/acp-client.test.ts test/issues.test.ts test/host-boundary.test.ts test/judge.test.ts test/batteries.test.ts test/host-engine.test.ts test/host-turn-durability.test.ts test/host-engine-asks.test.ts test/tool-concurrency.test.ts test/media.test.ts test/examples.test.ts test/mcphandler.test.ts test/mcphandler-conformance.test.ts test/mcp2026.test.ts test/serve-mcp-conformance.test.ts",
|
|
21
21
|
"example": "node --experimental-strip-types examples/basic.ts",
|
|
22
22
|
"example:judge": "node --experimental-strip-types examples/judge.ts",
|
|
23
23
|
"example:multimodal": "node --experimental-strip-types examples/multimodal.ts",
|