toolnexus 0.26.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.
@@ -4,10 +4,12 @@
4
4
  * pending call instead of hanging. No headers, no auth.
5
5
  */
6
6
  import { spawn, type ChildProcess } from "node:child_process"
7
- import { createInterface } from "node:readline"
8
7
  import type { Readable } from "node:stream"
9
8
  import type { Transport } from "./client.js"
10
9
  import type { Obj } from "./protocol.js"
10
+ import type { Transport as SdkTransport } from "@modelcontextprotocol/sdk/shared/transport.js"
11
+ import { ReadBuffer, serializeMessage } from "@modelcontextprotocol/sdk/shared/stdio.js"
12
+ import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"
11
13
 
12
14
  /** The child exited / closed stdout before answering. */
13
15
  export class StdioClosedError extends Error {}
@@ -18,33 +20,89 @@ export class StdioTransport implements Transport {
18
20
  private done: Promise<void>
19
21
  private child: ChildProcess
20
22
 
23
+ private buf: Buffer = Buffer.alloc(0)
24
+ private onData: (chunk: Buffer) => void
25
+ private onEnd: () => void
26
+ private detached = false
27
+ private hold = false
28
+ private held = false
29
+
21
30
  constructor(child: ChildProcess) {
22
31
  this.child = child
23
- const rl = createInterface({ input: child.stdout as Readable, crlfDelay: Infinity })
24
- rl.on("line", (line) => {
25
- let m: any
26
- try {
27
- m = JSON.parse(line)
28
- } catch {
29
- return // noise
30
- }
31
- if (!m || typeof m !== "object" || m.method !== undefined || m.id === undefined || m.id === null) return // not a response
32
- const key = JSON.stringify(m.id)
33
- const f = this.pending.get(key)
34
- this.pending.delete(key)
35
- f?.(m)
36
- })
37
- this.done = new Promise((r) =>
38
- rl.once("close", () => {
39
- this.err ??= new StdioClosedError("stdio server closed its stdout")
40
- for (const f of this.pending.values()) f({ __closed: true })
41
- this.pending.clear()
42
- r()
43
- }),
44
- )
32
+ // Line-exact own reader (no readline): detach() can stop it and hand over unread bytes.
33
+ let closed!: () => void
34
+ this.done = new Promise<void>((r) => (closed = r))
35
+ this.onData = (chunk: Buffer) => {
36
+ this.buf = this.buf.length ? Buffer.concat([this.buf, chunk]) : chunk
37
+ this.drain()
38
+ }
39
+ this.onEnd = () => {
40
+ this.err ??= new StdioClosedError("stdio server closed its stdout")
41
+ for (const f of this.pending.values()) f({ __closed: true })
42
+ this.pending.clear()
43
+ closed()
44
+ }
45
+ const out = child.stdout as Readable
46
+ out.on("data", this.onData)
47
+ out.once("end", this.onEnd)
48
+ out.once("close", this.onEnd)
45
49
  child.stdin?.on("error", () => {}) // EPIPE after the server died surfaces as a closed stdout
46
50
  }
47
51
 
52
+ private drain(): void {
53
+ let nl: number
54
+ while (!this.detached && !this.held && (nl = this.buf.indexOf(10)) >= 0) {
55
+ const line = this.buf.subarray(0, nl).toString("utf8").replace(/\r$/, "")
56
+ this.buf = this.buf.subarray(nl + 1)
57
+ this.onLine(line)
58
+ }
59
+ }
60
+
61
+ /** Probe mode: after the next delivered response, stop parsing (bytes stay buffered for detach). */
62
+ holdAfterNextResponse(): void {
63
+ this.hold = true
64
+ }
65
+
66
+ /** Leave probe mode and parse whatever was held. */
67
+ release(): void {
68
+ this.hold = false
69
+ this.held = false
70
+ this.drain()
71
+ }
72
+
73
+ private onLine(line: string): void {
74
+ let m: any
75
+ try {
76
+ m = JSON.parse(line)
77
+ } catch {
78
+ return // noise
79
+ }
80
+ if (!m || typeof m !== "object" || m.method !== undefined || m.id === undefined || m.id === null) return // not a response
81
+ const key = JSON.stringify(m.id)
82
+ const f = this.pending.get(key)
83
+ this.pending.delete(key)
84
+ if (f && this.hold) this.held = true
85
+ f?.(m)
86
+ }
87
+
88
+ /**
89
+ * §2.2a handoff: stop reading stdout (listeners removed, stream paused — no read left pending)
90
+ * and give the live child plus any bytes already read past the last delivered line to the
91
+ * frozen path. The transport is unusable afterwards; the child is NOT killed.
92
+ */
93
+ detach(): { child: ChildProcess; leftover: Buffer } {
94
+ this.detached = true
95
+ const out = this.child.stdout as Readable
96
+ out.off("data", this.onData)
97
+ out.off("end", this.onEnd)
98
+ out.off("close", this.onEnd)
99
+ out.pause()
100
+ this.err ??= new StdioClosedError("stdio transport detached")
101
+ const leftover = this.buf
102
+ this.buf = Buffer.alloc(0)
103
+ return { child: this.child, leftover }
104
+ }
105
+
48
106
  /** Writes one line and waits for the response with the same id. Never retried. */
49
107
  roundTrip(id: number, body: string, timeoutMs: number, signal?: AbortSignal): Promise<Obj> {
50
108
  if (this.err) return Promise.reject(this.err)
@@ -100,3 +158,70 @@ export function spawnStdio(argv: string[], env: NodeJS.ProcessEnv, cwd?: string)
100
158
  child.once("spawn", () => resolve(new StdioTransport(child)))
101
159
  })
102
160
  }
161
+
162
+ /**
163
+ * §2.2a: the frozen SDK path over a child the probe already started (StdioClientTransport always
164
+ * spawns). Takes the detached probe's stdin/stdout plus any bytes it had read but not parsed.
165
+ */
166
+ export class AdoptedStdioTransport implements SdkTransport {
167
+ onclose?: () => void
168
+ onerror?: (error: Error) => void
169
+ onmessage?: (message: JSONRPCMessage) => void
170
+ private rb = new ReadBuffer()
171
+ private closed = false
172
+
173
+ constructor(private child: ChildProcess, private leftover: Buffer) {}
174
+
175
+ async start(): Promise<void> {
176
+ const out = this.child.stdout as Readable
177
+ out.on("data", (chunk: Buffer) => this.feed(chunk))
178
+ out.on("error", (e) => this.onerror?.(e))
179
+ this.child.stdin?.on("error", (e) => this.onerror?.(e))
180
+ this.child.once("close", () => this.finish())
181
+ if (this.leftover.length) this.feed(this.leftover)
182
+ this.leftover = Buffer.alloc(0)
183
+ out.resume()
184
+ }
185
+
186
+ private feed(chunk: Buffer): void {
187
+ this.rb.append(chunk)
188
+ for (;;) {
189
+ let m: JSONRPCMessage | null
190
+ try {
191
+ m = this.rb.readMessage()
192
+ } catch (e) {
193
+ this.onerror?.(e as Error)
194
+ continue
195
+ }
196
+ if (m === null) break
197
+ this.onmessage?.(m)
198
+ }
199
+ }
200
+
201
+ send(message: JSONRPCMessage): Promise<void> {
202
+ return new Promise((resolve) => {
203
+ if (!this.child.stdin?.write(serializeMessage(message))) this.child.stdin?.once("drain", resolve)
204
+ else resolve()
205
+ })
206
+ }
207
+
208
+ private finish(): void {
209
+ if (this.closed) return
210
+ this.closed = true
211
+ this.rb.clear()
212
+ this.onclose?.()
213
+ }
214
+
215
+ /** Close stdin, then SIGKILL after 2 s if the child is still alive. */
216
+ async close(): Promise<void> {
217
+ if (this.child.exitCode === null && this.child.signalCode === null) {
218
+ const exited = new Promise<void>((r) => this.child.once("exit", () => r()))
219
+ this.child.stdin?.end()
220
+ const t = setTimeout(() => this.child.kill("SIGKILL"), 2000)
221
+ await exited
222
+ clearTimeout(t)
223
+ }
224
+ this.child.stdout?.destroy()
225
+ this.finish()
226
+ }
227
+ }
package/src/mcphandler.ts CHANGED
@@ -348,7 +348,7 @@ export function createMcpHandler(toolkitTools: () => Tool[], cfg: McpHandlerConf
348
348
  /** Validates protocol header + session (stateful). Writes the HTTP error itself on failure. */
349
349
  async function checkSession(ctx: McpContext, req: IncomingMessage, res: ServerResponse): Promise<boolean> {
350
350
  const v = header(req, "MCP-Protocol-Version")
351
- if (v !== "" && !legacy.includes(v)) {
351
+ if (v !== "" && !versions.includes(v)) {
352
352
  httpError(res, 400, "Bad Request: unsupported MCP-Protocol-Version")
353
353
  return false
354
354
  }
@@ -775,8 +775,7 @@ export function createMcpHandler(toolkitTools: () => Tool[], cfg: McpHandlerConf
775
775
  if (only2026) return true
776
776
  if (method === "initialize") return false
777
777
  const hv = header(req, "MCP-Protocol-Version")
778
- if (hv === MCP_PROTOCOL_2026) return true
779
- if (hv !== "" && versions.includes(hv)) return false
778
+ if (hv !== "" && legacy.includes(hv)) return false
780
779
  return method === "server/discover" || header(req, "Mcp-Method") !== "" || (meta !== undefined && META_VERSION in meta)
781
780
  }
782
781
 
package/src/mcpserve.ts CHANGED
@@ -16,7 +16,6 @@ import http from "node:http"
16
16
  import type { AddressInfo } from "node:net"
17
17
  import { randomBytes, timingSafeEqual } from "node:crypto"
18
18
  import { Server } from "@modelcontextprotocol/sdk/server/index.js"
19
- import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"
20
19
  import { ListToolsRequestSchema, CallToolRequestSchema, McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"
21
20
  import type { Tool, ToolSource, ToolResult } from "./types.js"
22
21
  import type { ContentPart } from "./content.js"
@@ -166,6 +165,8 @@ export interface LocalMcpServer {
166
165
  */
167
166
  export async function serveLocalMcp(tools: Tool[], cfg?: MCPServeConfig): Promise<LocalMcpServer> {
168
167
  const exposed = exposedMcpTools(tools, cfg)
168
+ // Loaded on first serve, not at import: the transport pulls in @hono/node-server.
169
+ const { StreamableHTTPServerTransport } = await import("@modelcontextprotocol/sdk/server/streamableHttp.js")
169
170
  const token = randomBytes(32).toString("hex")
170
171
  const want = Buffer.from("Bearer " + token)
171
172
  const server = http.createServer((req, res) => {
package/src/serve.ts CHANGED
@@ -18,7 +18,8 @@ import http from "node:http"
18
18
  import fs from "node:fs"
19
19
  import path from "node:path"
20
20
  import { randomUUID } from "node:crypto"
21
- import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"
21
+ // The SDK server transport (and the @hono/node-server it pulls in) is loaded only when an MCP
22
+ // profile is actually served, so importing toolnexus does not pay for it.
22
23
  import { sanitize, type Tool } from "./types.js"
23
24
  import type { SkillInfo } from "./skill.js"
24
25
  import type { RunResult } from "./client.js"
@@ -213,6 +214,9 @@ export async function startA2AServer(opts: StartServerOptions): Promise<ServeHan
213
214
  const [host, portStr] = splitAddr(opts.addr)
214
215
  const store = a2a ? resolveStore(a2a.store) : undefined
215
216
  const exposedTools = mcp ? exposedMcpTools(mcpTools ?? [], mcp) : []
217
+ const StreamableHTTPServerTransport = mcp
218
+ ? (await import("@modelcontextprotocol/sdk/server/streamableHttp.js")).StreamableHTTPServerTransport
219
+ : undefined
216
220
 
217
221
  const server = http.createServer((req, res) => {
218
222
  const rpcRes = (id: unknown, payload: object) => {
@@ -244,7 +248,7 @@ export async function startA2AServer(opts: StartServerOptions): Promise<ServeHan
244
248
  const body = req.method === "POST" ? await readBody(req) : ""
245
249
  const parsed = body ? safeJson(body) : undefined
246
250
  const mcpServer = buildMcpServer(exposedTools, mcp, onCall)
247
- const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })
251
+ const transport = new StreamableHTTPServerTransport!({ sessionIdGenerator: undefined })
248
252
  res.on("close", () => {
249
253
  void transport.close()
250
254
  void mcpServer.close()
package/src/skill.ts CHANGED
@@ -12,10 +12,19 @@
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
17
  import type { Tool, ToolResult } from "./types.js"
18
18
 
19
+ // yaml is required on the first frontmatter parse, not at import, so a host with no skills never
20
+ // loads it. `require` keeps the parse synchronous (listSkills/loadSkills are sync); yaml's Node
21
+ // entry is CommonJS, so this is the same module instance an `import` would get.
22
+ let yamlParse: ((src: string) => unknown) | undefined
23
+ function parseYaml(src: string): unknown {
24
+ yamlParse ??= (createRequire(import.meta.url)("yaml") as typeof import("yaml")).parse
25
+ return yamlParse(src)
26
+ }
27
+
19
28
  export const SKILL_TOOL_DESCRIPTION = `Load a specialized skill when the task at hand matches one of the skills listed in the system prompt.
20
29
 
21
30
  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.
@@ -68,10 +77,25 @@ export interface SkillSkip {
68
77
  detail?: string
69
78
  }
70
79
 
80
+ /** Why a KEPT skill carries a warning (SPEC.md §3, #84). The skill still loads unchanged. */
81
+ export type SkillWarningReason = "oversized-description"
82
+
83
+ /** Maximum skill `description` length, in Unicode code points, before a warning (SPEC.md §3). */
84
+ export const SKILL_DESCRIPTION_MAX_CHARS = 1024
85
+
86
+ export interface SkillWarning {
87
+ location: string
88
+ reason: SkillWarningReason
89
+ /** Description length in Unicode CODE POINTS (not bytes, not UTF-16 units). */
90
+ chars: number
91
+ }
92
+
71
93
  /** Result of a list-only validate pass (SPEC.md §3, S3). */
72
94
  export interface SkillInventory {
73
95
  skills: SkillInfo[]
74
96
  skipped: SkillSkip[]
97
+ /** Kept skills that load but carry a warning (oversized description), in discovery order. */
98
+ warnings: SkillWarning[]
75
99
  }
76
100
 
77
101
  /** Options for loadSkills / listSkills (SPEC.md §3, S1/S2/S5). */
@@ -353,6 +377,26 @@ function collectCandidates(opts: LoadSkillsOptions): RawCandidate[] {
353
377
  return cands
354
378
  }
355
379
 
380
+ /**
381
+ * Warn — never modify — skills whose description exceeds SKILL_DESCRIPTION_MAX_CHARS code
382
+ * points (SPEC.md §3, #84). Each warning is also sent to the same channel as the
383
+ * duplicate-name warning. Order follows the given skills (discovery order).
384
+ */
385
+ function describeWarnings(skills: SkillInfo[]): SkillWarning[] {
386
+ const out: SkillWarning[] = []
387
+ for (const s of skills) {
388
+ if (s.description === undefined) continue
389
+ const chars = [...s.description].length
390
+ if (chars > SKILL_DESCRIPTION_MAX_CHARS) {
391
+ console.warn(
392
+ `[toolnexus] skill "${s.name}" (${s.location}) description is ${chars} chars, over ${SKILL_DESCRIPTION_MAX_CHARS} — kept unmodified`,
393
+ )
394
+ out.push({ location: s.location, reason: "oversized-description", chars })
395
+ }
396
+ }
397
+ return out
398
+ }
399
+
356
400
  /** Dedupe candidates by name (first-wins); later duplicates become skips. */
357
401
  function mergeCandidates(cands: RawCandidate[]): { skills: Record<string, SkillInfo>; skipped: SkillSkip[] } {
358
402
  const skills: Record<string, SkillInfo> = {}
@@ -409,6 +453,8 @@ export interface SkillSource {
409
453
  * that stops being reported is indistinguishable from something that was finished.
410
454
  */
411
455
  skipped: SkillSkip[]
456
+ /** Exposed (post-allowlist) skills whose description is oversized; never modified (#84). */
457
+ warnings: SkillWarning[]
412
458
  }
413
459
 
414
460
  /**
@@ -420,7 +466,8 @@ export function listSkills(input: string | string[] | LoadSkillsOptions): SkillI
420
466
  const opts: LoadSkillsOptions =
421
467
  typeof input === "string" || Array.isArray(input) ? { dirs: input } : input
422
468
  const merged = mergeCandidates(collectCandidates(opts))
423
- return { skills: Object.values(merged.skills), skipped: merged.skipped }
469
+ const list = Object.values(merged.skills)
470
+ return { skills: list, skipped: merged.skipped, warnings: describeWarnings(list) }
424
471
  }
425
472
 
426
473
  /** Discover skills (dirs and/or data) and build the `skill` loader tool. */
@@ -432,6 +479,7 @@ export function loadSkills(input: string | string[] | LoadSkillsOptions): SkillS
432
479
  const skills = applySkillsFilter(merged.skills, opts.filter)
433
480
 
434
481
  const skipped = merged.skipped
482
+ const warnings = describeWarnings(Object.values(skills))
435
483
  const tool: Tool = {
436
484
  name: "skill",
437
485
  description: SKILL_TOOL_DESCRIPTION,
@@ -499,6 +547,7 @@ export function loadSkills(input: string | string[] | LoadSkillsOptions): SkillS
499
547
  skills,
500
548
  tool,
501
549
  skipped,
550
+ warnings,
502
551
  prompt() {
503
552
  const described = Object.values(skills)
504
553
  .filter((s) => s.description !== undefined)