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/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 Question, type RecordedDecision } from "./classifier.js";
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: 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, Question>;
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 { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
10
- import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
9
+ import { AdoptedStdioTransport } from "./mcp2026/stdio.js";
11
10
  import { 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
- await raceTimeout(connectTransport(client, cfg, oauth ? { oauth, waitFor: opts.waitFor } : undefined), timeout, opts.signal);
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";
@@ -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;
@@ -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
- const tr = await spawnStdio(t.local.argv, t.local.env, t.local.cwd);
86
- client = new Mcp2026Client("", { transport: tr, timeoutMs: t.timeout });
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();
@@ -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
+ }
@@ -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 { createInterface } from "node:readline";
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
- const rl = createInterface({ input: child.stdout, crlfDelay: Infinity });
19
- rl.on("line", (line) => {
20
- let m;
21
- try {
22
- m = JSON.parse(line);
23
- }
24
- catch {
25
- return; // noise
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
- r();
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
+ }
@@ -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 !== "" && !legacy.includes(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 === MCP_PROTOCOL_2026)
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
- import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
21
+ // The SDK server transport (and the @hono/node-server it pulls in) is loaded only when an MCP
22
+ // profile is actually served, so importing toolnexus does not pay for it.
22
23
  import { sanitize } from "./types.js";
23
24
  import { buildMcpServer, exposedMcpTools } from "./mcpserve.js";
24
25
  /** Default in-memory store — Tasks live only for the lifetime of the process. */
@@ -136,6 +137,9 @@ export async function startA2AServer(opts) {
136
137
  const [host, portStr] = splitAddr(opts.addr);
137
138
  const store = a2a ? resolveStore(a2a.store) : undefined;
138
139
  const exposedTools = mcp ? exposedMcpTools(mcpTools ?? [], mcp) : [];
140
+ const StreamableHTTPServerTransport = mcp
141
+ ? (await import("@modelcontextprotocol/sdk/server/streamableHttp.js")).StreamableHTTPServerTransport
142
+ : undefined;
139
143
  const server = http.createServer((req, res) => {
140
144
  const rpcRes = (id, payload) => {
141
145
  res.writeHead(200, { "content-type": "application/json" });
@@ -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 { parse as parseYaml } from "yaml";
15
+ import { createRequire } from "node:module";
16
16
  import { compareCodePoints, compareDiscovery, sortEntriesByName } from "./order.js";
17
+ // yaml is required on the first frontmatter parse, not at import, so a host with no skills never
18
+ // loads it. `require` keeps the parse synchronous (listSkills/loadSkills are sync); yaml's Node
19
+ // entry is CommonJS, so this is the same module instance an `import` would get.
20
+ let yamlParse;
21
+ function parseYaml(src) {
22
+ yamlParse ??= createRequire(import.meta.url)("yaml").parse;
23
+ return yamlParse(src);
24
+ }
17
25
  export const SKILL_TOOL_DESCRIPTION = `Load a specialized skill when the task at hand matches one of the skills listed in the system prompt.
18
26
 
19
27
  Use this tool to inject the skill's instructions and resources into current conversation. The output may contain detailed workflow guidance as well as references to scripts, files, etc in the same directory as the skill.
@@ -25,6 +33,8 @@ The skill name must match one of the skills listed in your system prompt.`;
25
33
  */
26
34
  export const SKILLS_PROMPT_PREAMBLE = "Skills provide specialized instructions and workflows for specific tasks.\n" +
27
35
  "Use the skill tool to load a skill when a task matches its description.";
36
+ /** Maximum skill `description` length, in Unicode code points, before a warning (SPEC.md §3). */
37
+ export const SKILL_DESCRIPTION_MAX_CHARS = 1024;
28
38
  /**
29
39
  * Parse YAML frontmatter (between the leading `---` fences) with a real YAML parser, so folded
30
40
  * (`>`)/literal (`|`) block scalars, quoting and multi-line values all resolve correctly.
@@ -304,6 +314,24 @@ function collectCandidates(opts) {
304
314
  cands.push(...candidatesFromDefs(opts.skills));
305
315
  return cands;
306
316
  }
317
+ /**
318
+ * Warn — never modify — skills whose description exceeds SKILL_DESCRIPTION_MAX_CHARS code
319
+ * points (SPEC.md §3, #84). Each warning is also sent to the same channel as the
320
+ * duplicate-name warning. Order follows the given skills (discovery order).
321
+ */
322
+ function describeWarnings(skills) {
323
+ const out = [];
324
+ for (const s of skills) {
325
+ if (s.description === undefined)
326
+ continue;
327
+ const chars = [...s.description].length;
328
+ if (chars > SKILL_DESCRIPTION_MAX_CHARS) {
329
+ console.warn(`[toolnexus] skill "${s.name}" (${s.location}) description is ${chars} chars, over ${SKILL_DESCRIPTION_MAX_CHARS} — kept unmodified`);
330
+ out.push({ location: s.location, reason: "oversized-description", chars });
331
+ }
332
+ }
333
+ return out;
334
+ }
307
335
  /** Dedupe candidates by name (first-wins); later duplicates become skips. */
308
336
  function mergeCandidates(cands) {
309
337
  const skills = {};
@@ -355,7 +383,8 @@ function applySkillsFilter(skills, filter) {
355
383
  export function listSkills(input) {
356
384
  const opts = typeof input === "string" || Array.isArray(input) ? { dirs: input } : input;
357
385
  const merged = mergeCandidates(collectCandidates(opts));
358
- return { skills: Object.values(merged.skills), skipped: merged.skipped };
386
+ const list = Object.values(merged.skills);
387
+ return { skills: list, skipped: merged.skipped, warnings: describeWarnings(list) };
359
388
  }
360
389
  /** Discover skills (dirs and/or data) and build the `skill` loader tool. */
361
390
  export function loadSkills(input) {
@@ -364,6 +393,7 @@ export function loadSkills(input) {
364
393
  const merged = mergeCandidates(collectCandidates(opts));
365
394
  const skills = applySkillsFilter(merged.skills, opts.filter);
366
395
  const skipped = merged.skipped;
396
+ const warnings = describeWarnings(Object.values(skills));
367
397
  const tool = {
368
398
  name: "skill",
369
399
  description: SKILL_TOOL_DESCRIPTION,
@@ -426,6 +456,7 @@ export function loadSkills(input) {
426
456
  skills,
427
457
  tool,
428
458
  skipped,
459
+ warnings,
429
460
  prompt() {
430
461
  const described = Object.values(skills)
431
462
  .filter((s) => s.description !== undefined)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolnexus",
3
- "version": "0.25.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",