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/src/client.ts
CHANGED
|
@@ -227,6 +227,12 @@ export type MetricEvent =
|
|
|
227
227
|
promptTokens: number
|
|
228
228
|
completionTokens: number
|
|
229
229
|
error?: string
|
|
230
|
+
/** L1: how many questions were asked. */
|
|
231
|
+
questions: number
|
|
232
|
+
/** L1: sha256 hex of `canonicalRequest(model, questions)` ("" when unencodable). */
|
|
233
|
+
questionsHash: string
|
|
234
|
+
/** True when the decision came from the classifier cache and no request was sent. */
|
|
235
|
+
cached: boolean
|
|
230
236
|
}
|
|
231
237
|
// `warning`, never `error`: a degenerate-criteria report is advisory, and a consumer that
|
|
232
238
|
// counts "has an error" as a failure must not count this.
|
package/src/judge.ts
CHANGED
|
@@ -12,8 +12,10 @@ import {
|
|
|
12
12
|
Decision,
|
|
13
13
|
type DecisionAnswer,
|
|
14
14
|
type NoulCriteria,
|
|
15
|
-
type
|
|
15
|
+
type AnyQuestion,
|
|
16
16
|
type RecordedDecision,
|
|
17
|
+
isRawAnswer,
|
|
18
|
+
raw as rawQ,
|
|
17
19
|
noul as noulQ,
|
|
18
20
|
choice as choiceQ,
|
|
19
21
|
score as scoreQ,
|
|
@@ -25,7 +27,7 @@ import type { Request } from "./types.js"
|
|
|
25
27
|
/** One named question of an ordered question list. */
|
|
26
28
|
export interface NamedQuestion {
|
|
27
29
|
name: string
|
|
28
|
-
question:
|
|
30
|
+
question: AnyQuestion
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
/** `noul` question with a name. Name the state field it judges in `instructions`. */
|
|
@@ -41,9 +43,15 @@ export function score(name: string, instructions: string, levels: string[]): Nam
|
|
|
41
43
|
return { name, question: scoreQ(instructions, levels) }
|
|
42
44
|
}
|
|
43
45
|
|
|
46
|
+
/** A raw question with a name: a System One type the library does not model (e.g. `box`), sent
|
|
47
|
+
* as written. Its answer decodes to a raw answer `{type, value}`. */
|
|
48
|
+
export function raw(name: string, type: string, instructions: string, criteria?: unknown): NamedQuestion {
|
|
49
|
+
return { name, question: rawQ(type, instructions, criteria) }
|
|
50
|
+
}
|
|
51
|
+
|
|
44
52
|
/** Ordered list -> the §8B questions map. A duplicate name is an error naming it. */
|
|
45
|
-
export function questionMap(questions: readonly NamedQuestion[]): Record<string,
|
|
46
|
-
const out: Record<string,
|
|
53
|
+
export function questionMap(questions: readonly NamedQuestion[]): Record<string, AnyQuestion> {
|
|
54
|
+
const out: Record<string, AnyQuestion> = {}
|
|
47
55
|
for (const { name, question } of questions) {
|
|
48
56
|
if (Object.prototype.hasOwnProperty.call(out, name)) throw new Error(`duplicate question name ${JSON.stringify(name)}`)
|
|
49
57
|
out[name] = question
|
|
@@ -93,6 +101,13 @@ function noulBand(p: number, b: Bands): Band {
|
|
|
93
101
|
}
|
|
94
102
|
|
|
95
103
|
function wrap(a: DecisionAnswer, b: Bands): Answer {
|
|
104
|
+
if (isRawAnswer(a)) {
|
|
105
|
+
// An unmodelled answer type is never sure: there is no number to band.
|
|
106
|
+
const out = { ...(a as object), sure: false } as Answer
|
|
107
|
+
Object.defineProperty(out, "value", { value: () => 0, enumerable: false })
|
|
108
|
+
Object.defineProperty(out, "pick", { value: () => "", enumerable: false })
|
|
109
|
+
return out
|
|
110
|
+
}
|
|
96
111
|
const extra =
|
|
97
112
|
a.type === "noul"
|
|
98
113
|
? { band: noulBand(a.noul, b) }
|
package/src/mcp.ts
CHANGED
|
@@ -6,18 +6,22 @@
|
|
|
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 type { Tool as McpToolDef, ElicitRequest, ElicitResult } from "@modelcontextprotocol/sdk/types.js"
|
|
12
11
|
import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"
|
|
13
12
|
import { type JSONSchema, type Tool, type ToolContext, type ToolHints, type ToolResult, type McpStatus, type Request, type Answer, sanitize } from "./types.js"
|
|
14
13
|
import { type ContentPart, summarizePart } from "./content.js"
|
|
15
14
|
import type { SkillDef } from "./skill.js"
|
|
16
|
-
import {
|
|
15
|
+
import { type AuthConfig, type ModernServer, type Load2026Options } from "./mcp2026/source.js"
|
|
17
16
|
import { PendingError, type OAuth, type TokenStore } from "./mcp2026/auth.js"
|
|
18
17
|
export { Token, MemoryTokenStore, type TokenStore } from "./mcp2026/auth.js"
|
|
19
18
|
export type { AuthConfig } from "./mcp2026/source.js"
|
|
20
19
|
|
|
20
|
+
// Loaded on first use, not at import: the 2026 client is only needed once a config is loaded,
|
|
21
|
+
// and the SDK's remote transports (streamable-HTTP, SSE) only when a remote server falls back.
|
|
22
|
+
let mcp2026: Promise<typeof import("./mcp2026/source.js")> | undefined
|
|
23
|
+
const loadMcp2026 = () => (mcp2026 ??= import("./mcp2026/source.js"))
|
|
24
|
+
|
|
21
25
|
// ---------------------------------------------------------------------------
|
|
22
26
|
// MCP elicitation bridge (§10). A server can ask US for input mid-`tools/call`
|
|
23
27
|
// (a reverse-request). We map it onto the one `waitFor`: form mode → kind:"input",
|
|
@@ -477,6 +481,10 @@ async function connectTransport(client: Client, cfg: ServerConfig, auth?: Fallba
|
|
|
477
481
|
const expanded = expandEnvHeaders(cfg.headers)
|
|
478
482
|
const requestInit = expanded ? { headers: expanded } : undefined
|
|
479
483
|
const fetch = auth ? bearerFetch(cfg.url, auth) : undefined
|
|
484
|
+
const [{ StreamableHTTPClientTransport }, { SSEClientTransport }] = await Promise.all([
|
|
485
|
+
import("@modelcontextprotocol/sdk/client/streamableHttp.js"),
|
|
486
|
+
import("@modelcontextprotocol/sdk/client/sse.js"),
|
|
487
|
+
])
|
|
480
488
|
try {
|
|
481
489
|
await client.connect(new StreamableHTTPClientTransport(url, { requestInit, ...(fetch ? { fetch } : {}) }))
|
|
482
490
|
} catch {
|
|
@@ -552,6 +560,7 @@ type Connected =
|
|
|
552
560
|
* path, with the OAuth bearer attached for remote servers. Throws ⇒ the server failed.
|
|
553
561
|
*/
|
|
554
562
|
async function connectServer(name: string, cfg: ServerConfig, timeout: number, opts: McpLoadOptions, store: TokenStore, sdkClient: () => Client, onSdk: (c: Client) => void): Promise<Connected> {
|
|
563
|
+
const { newOAuth, probe } = await loadMcp2026()
|
|
555
564
|
const o: Load2026Options = { waitFor: opts.waitFor, tokenStore: store, openUrl: opts.openUrl, signal: opts.signal, disableTools: opts.disableTools }
|
|
556
565
|
const remote = isRemote(cfg)
|
|
557
566
|
const oauth = remote ? newOAuth(cfg.auth, o, store) : undefined
|
|
@@ -570,7 +579,10 @@ async function connectServer(name: string, cfg: ServerConfig, timeout: number, o
|
|
|
570
579
|
if (p.kind === "modern") return p
|
|
571
580
|
const client = sdkClient()
|
|
572
581
|
onSdk(client)
|
|
573
|
-
|
|
582
|
+
const connecting = p.reuse
|
|
583
|
+
? client.connect(new AdoptedStdioTransport(p.reuse.child, p.reuse.leftover)) // §2.2a: same child, no respawn
|
|
584
|
+
: connectTransport(client, cfg, oauth ? { oauth, waitFor: opts.waitFor } : undefined)
|
|
585
|
+
await raceTimeout(connecting, timeout, opts.signal)
|
|
574
586
|
const defs = await raceTimeout(paginateTools(client, timeout), timeout, opts.signal)
|
|
575
587
|
return { kind: "sdk", client, defs }
|
|
576
588
|
}
|
|
@@ -596,7 +608,7 @@ export async function loadMcp(
|
|
|
596
608
|
const clients: { close(): Promise<void> }[] = []
|
|
597
609
|
const servers = new Map<string, ServerConn>()
|
|
598
610
|
const skills: SkillDef[] = []
|
|
599
|
-
const store = defaultTokenStore({ tokenStore: opts?.tokenStore })
|
|
611
|
+
const store = (await loadMcp2026()).defaultTokenStore({ tokenStore: opts?.tokenStore })
|
|
600
612
|
const waitFor = opts?.waitFor
|
|
601
613
|
const signal = opts?.signal
|
|
602
614
|
const onServer = opts?.onServer
|
|
@@ -701,7 +713,7 @@ export async function listMcpTools(input: string | object, opts?: McpLoadOptions
|
|
|
701
713
|
const toolsByServer: Record<string, ToolInfo[]> = {}
|
|
702
714
|
const status: Record<string, McpStatus> = {}
|
|
703
715
|
const signal = opts?.signal
|
|
704
|
-
const store = defaultTokenStore({ tokenStore: opts?.tokenStore })
|
|
716
|
+
const store = (await loadMcp2026()).defaultTokenStore({ tokenStore: opts?.tokenStore })
|
|
705
717
|
|
|
706
718
|
await Promise.all(
|
|
707
719
|
Object.entries(config).map(async ([name, cfg]) => {
|
package/src/mcp2026/source.ts
CHANGED
|
@@ -8,7 +8,8 @@ import { randomBytes } from "node:crypto"
|
|
|
8
8
|
import { Mcp2026Client } from "./client.js"
|
|
9
9
|
import { HttpStatusError, NotJsonRpcError, PROTOCOL_VERSION, RpcError, SKILLS_EXT, type Obj } from "./protocol.js"
|
|
10
10
|
import { LoopbackAuthorizer, MemoryTokenStore, OAuth, PauseAuthorizer, PendingError, type TokenStore } from "./auth.js"
|
|
11
|
-
import { spawnStdio, StdioClosedError } from "./stdio.js"
|
|
11
|
+
import { spawnStdio, StdioClosedError, type StdioTransport } from "./stdio.js"
|
|
12
|
+
import type { ChildProcess } from "node:child_process"
|
|
12
13
|
import { rank } from "./toolsearch.js"
|
|
13
14
|
import type { Answer, JSONSchema, Request, Tool, ToolContext, ToolResult } from "../types.js"
|
|
14
15
|
import { sanitize } from "../types.js"
|
|
@@ -88,7 +89,9 @@ async function authed(oauth: OAuth | undefined, waitFor: WaitFor | undefined, f:
|
|
|
88
89
|
}
|
|
89
90
|
|
|
90
91
|
/** What the probe decided. `fallback` ⇒ take the frozen SDK path; anything thrown ⇒ status failed. */
|
|
91
|
-
export type Probe =
|
|
92
|
+
export type Probe =
|
|
93
|
+
| { kind: "modern"; server: ModernServer }
|
|
94
|
+
| { kind: "fallback"; reuse?: { child: ChildProcess; leftover: Buffer } }
|
|
92
95
|
|
|
93
96
|
/** §2.2a: which discover failures mean "old server". */
|
|
94
97
|
function isFallbackError(e: unknown): boolean {
|
|
@@ -115,11 +118,14 @@ export interface ProbeTarget {
|
|
|
115
118
|
/** Discover first (§2.2). Resolves `modern` (tools listed) or `fallback`; throws on a load failure. */
|
|
116
119
|
export async function probe(t: ProbeTarget, o: Load2026Options): Promise<Probe> {
|
|
117
120
|
let client: Mcp2026Client
|
|
121
|
+
let stdio: StdioTransport | undefined
|
|
118
122
|
if (t.remote) {
|
|
119
123
|
client = new Mcp2026Client(t.remote.url, { headers: t.remote.headers, auth: t.remote.oauth, timeoutMs: t.timeout })
|
|
120
124
|
} else {
|
|
121
|
-
|
|
122
|
-
|
|
125
|
+
stdio = await spawnStdio(t.local!.argv, t.local!.env, t.local!.cwd)
|
|
126
|
+
// Stop parsing right after the discover reply so an old server's child can be handed over.
|
|
127
|
+
stdio.holdAfterNextResponse()
|
|
128
|
+
client = new Mcp2026Client("", { transport: stdio, timeoutMs: t.timeout })
|
|
123
129
|
}
|
|
124
130
|
const oauth = t.remote?.oauth
|
|
125
131
|
let disc: Obj
|
|
@@ -129,6 +135,8 @@ export async function probe(t: ProbeTarget, o: Load2026Options): Promise<Probe>
|
|
|
129
135
|
disc = await authed(oauth, o.waitFor, () => client.call("server/discover", {}, { timeoutMs, signal: o.signal }))
|
|
130
136
|
} catch (e) {
|
|
131
137
|
const stdioTimeout = !!t.local && e instanceof Error && e.message === "stdio request timed out"
|
|
138
|
+
// §2.2a: a fast JSON-RPC error over stdio ⇒ the SAME child continues into initialize.
|
|
139
|
+
if (stdio && e instanceof RpcError && !o.signal?.aborted) return { kind: "fallback", reuse: stdio.detach() }
|
|
132
140
|
await client.close().catch(() => {})
|
|
133
141
|
if (o.signal?.aborted) throw e
|
|
134
142
|
if (e instanceof StdioClosedError) throw e
|
|
@@ -136,9 +144,11 @@ export async function probe(t: ProbeTarget, o: Load2026Options): Promise<Probe>
|
|
|
136
144
|
throw e
|
|
137
145
|
}
|
|
138
146
|
if (!supports2026(disc)) {
|
|
147
|
+
if (stdio) return { kind: "fallback", reuse: stdio.detach() }
|
|
139
148
|
await client.close().catch(() => {})
|
|
140
149
|
return { kind: "fallback" }
|
|
141
150
|
}
|
|
151
|
+
stdio?.release()
|
|
142
152
|
try {
|
|
143
153
|
const server = new ModernServer(t, client, disc, o)
|
|
144
154
|
await server.load()
|
package/src/mcp2026/stdio.ts
CHANGED
|
@@ -4,10 +4,12 @@
|
|
|
4
4
|
* pending call instead of hanging. No headers, no auth.
|
|
5
5
|
*/
|
|
6
6
|
import { spawn, type ChildProcess } from "node:child_process"
|
|
7
|
-
import { createInterface } from "node:readline"
|
|
8
7
|
import type { Readable } from "node:stream"
|
|
9
8
|
import type { Transport } from "./client.js"
|
|
10
9
|
import type { Obj } from "./protocol.js"
|
|
10
|
+
import type { Transport as SdkTransport } from "@modelcontextprotocol/sdk/shared/transport.js"
|
|
11
|
+
import { ReadBuffer, serializeMessage } from "@modelcontextprotocol/sdk/shared/stdio.js"
|
|
12
|
+
import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"
|
|
11
13
|
|
|
12
14
|
/** The child exited / closed stdout before answering. */
|
|
13
15
|
export class StdioClosedError extends Error {}
|
|
@@ -18,33 +20,89 @@ export class StdioTransport implements Transport {
|
|
|
18
20
|
private done: Promise<void>
|
|
19
21
|
private child: ChildProcess
|
|
20
22
|
|
|
23
|
+
private buf: Buffer = Buffer.alloc(0)
|
|
24
|
+
private onData: (chunk: Buffer) => void
|
|
25
|
+
private onEnd: () => void
|
|
26
|
+
private detached = false
|
|
27
|
+
private hold = false
|
|
28
|
+
private held = false
|
|
29
|
+
|
|
21
30
|
constructor(child: ChildProcess) {
|
|
22
31
|
this.child = child
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
this.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
for (const f of this.pending.values()) f({ __closed: true })
|
|
41
|
-
this.pending.clear()
|
|
42
|
-
r()
|
|
43
|
-
}),
|
|
44
|
-
)
|
|
32
|
+
// Line-exact own reader (no readline): detach() can stop it and hand over unread bytes.
|
|
33
|
+
let closed!: () => void
|
|
34
|
+
this.done = new Promise<void>((r) => (closed = r))
|
|
35
|
+
this.onData = (chunk: Buffer) => {
|
|
36
|
+
this.buf = this.buf.length ? Buffer.concat([this.buf, chunk]) : chunk
|
|
37
|
+
this.drain()
|
|
38
|
+
}
|
|
39
|
+
this.onEnd = () => {
|
|
40
|
+
this.err ??= new StdioClosedError("stdio server closed its stdout")
|
|
41
|
+
for (const f of this.pending.values()) f({ __closed: true })
|
|
42
|
+
this.pending.clear()
|
|
43
|
+
closed()
|
|
44
|
+
}
|
|
45
|
+
const out = child.stdout as Readable
|
|
46
|
+
out.on("data", this.onData)
|
|
47
|
+
out.once("end", this.onEnd)
|
|
48
|
+
out.once("close", this.onEnd)
|
|
45
49
|
child.stdin?.on("error", () => {}) // EPIPE after the server died surfaces as a closed stdout
|
|
46
50
|
}
|
|
47
51
|
|
|
52
|
+
private drain(): void {
|
|
53
|
+
let nl: number
|
|
54
|
+
while (!this.detached && !this.held && (nl = this.buf.indexOf(10)) >= 0) {
|
|
55
|
+
const line = this.buf.subarray(0, nl).toString("utf8").replace(/\r$/, "")
|
|
56
|
+
this.buf = this.buf.subarray(nl + 1)
|
|
57
|
+
this.onLine(line)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Probe mode: after the next delivered response, stop parsing (bytes stay buffered for detach). */
|
|
62
|
+
holdAfterNextResponse(): void {
|
|
63
|
+
this.hold = true
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Leave probe mode and parse whatever was held. */
|
|
67
|
+
release(): void {
|
|
68
|
+
this.hold = false
|
|
69
|
+
this.held = false
|
|
70
|
+
this.drain()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private onLine(line: string): void {
|
|
74
|
+
let m: any
|
|
75
|
+
try {
|
|
76
|
+
m = JSON.parse(line)
|
|
77
|
+
} catch {
|
|
78
|
+
return // noise
|
|
79
|
+
}
|
|
80
|
+
if (!m || typeof m !== "object" || m.method !== undefined || m.id === undefined || m.id === null) return // not a response
|
|
81
|
+
const key = JSON.stringify(m.id)
|
|
82
|
+
const f = this.pending.get(key)
|
|
83
|
+
this.pending.delete(key)
|
|
84
|
+
if (f && this.hold) this.held = true
|
|
85
|
+
f?.(m)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* §2.2a handoff: stop reading stdout (listeners removed, stream paused — no read left pending)
|
|
90
|
+
* and give the live child plus any bytes already read past the last delivered line to the
|
|
91
|
+
* frozen path. The transport is unusable afterwards; the child is NOT killed.
|
|
92
|
+
*/
|
|
93
|
+
detach(): { child: ChildProcess; leftover: Buffer } {
|
|
94
|
+
this.detached = true
|
|
95
|
+
const out = this.child.stdout as Readable
|
|
96
|
+
out.off("data", this.onData)
|
|
97
|
+
out.off("end", this.onEnd)
|
|
98
|
+
out.off("close", this.onEnd)
|
|
99
|
+
out.pause()
|
|
100
|
+
this.err ??= new StdioClosedError("stdio transport detached")
|
|
101
|
+
const leftover = this.buf
|
|
102
|
+
this.buf = Buffer.alloc(0)
|
|
103
|
+
return { child: this.child, leftover }
|
|
104
|
+
}
|
|
105
|
+
|
|
48
106
|
/** Writes one line and waits for the response with the same id. Never retried. */
|
|
49
107
|
roundTrip(id: number, body: string, timeoutMs: number, signal?: AbortSignal): Promise<Obj> {
|
|
50
108
|
if (this.err) return Promise.reject(this.err)
|
|
@@ -100,3 +158,70 @@ export function spawnStdio(argv: string[], env: NodeJS.ProcessEnv, cwd?: string)
|
|
|
100
158
|
child.once("spawn", () => resolve(new StdioTransport(child)))
|
|
101
159
|
})
|
|
102
160
|
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* §2.2a: the frozen SDK path over a child the probe already started (StdioClientTransport always
|
|
164
|
+
* spawns). Takes the detached probe's stdin/stdout plus any bytes it had read but not parsed.
|
|
165
|
+
*/
|
|
166
|
+
export class AdoptedStdioTransport implements SdkTransport {
|
|
167
|
+
onclose?: () => void
|
|
168
|
+
onerror?: (error: Error) => void
|
|
169
|
+
onmessage?: (message: JSONRPCMessage) => void
|
|
170
|
+
private rb = new ReadBuffer()
|
|
171
|
+
private closed = false
|
|
172
|
+
|
|
173
|
+
constructor(private child: ChildProcess, private leftover: Buffer) {}
|
|
174
|
+
|
|
175
|
+
async start(): Promise<void> {
|
|
176
|
+
const out = this.child.stdout as Readable
|
|
177
|
+
out.on("data", (chunk: Buffer) => this.feed(chunk))
|
|
178
|
+
out.on("error", (e) => this.onerror?.(e))
|
|
179
|
+
this.child.stdin?.on("error", (e) => this.onerror?.(e))
|
|
180
|
+
this.child.once("close", () => this.finish())
|
|
181
|
+
if (this.leftover.length) this.feed(this.leftover)
|
|
182
|
+
this.leftover = Buffer.alloc(0)
|
|
183
|
+
out.resume()
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private feed(chunk: Buffer): void {
|
|
187
|
+
this.rb.append(chunk)
|
|
188
|
+
for (;;) {
|
|
189
|
+
let m: JSONRPCMessage | null
|
|
190
|
+
try {
|
|
191
|
+
m = this.rb.readMessage()
|
|
192
|
+
} catch (e) {
|
|
193
|
+
this.onerror?.(e as Error)
|
|
194
|
+
continue
|
|
195
|
+
}
|
|
196
|
+
if (m === null) break
|
|
197
|
+
this.onmessage?.(m)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
send(message: JSONRPCMessage): Promise<void> {
|
|
202
|
+
return new Promise((resolve) => {
|
|
203
|
+
if (!this.child.stdin?.write(serializeMessage(message))) this.child.stdin?.once("drain", resolve)
|
|
204
|
+
else resolve()
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private finish(): void {
|
|
209
|
+
if (this.closed) return
|
|
210
|
+
this.closed = true
|
|
211
|
+
this.rb.clear()
|
|
212
|
+
this.onclose?.()
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Close stdin, then SIGKILL after 2 s if the child is still alive. */
|
|
216
|
+
async close(): Promise<void> {
|
|
217
|
+
if (this.child.exitCode === null && this.child.signalCode === null) {
|
|
218
|
+
const exited = new Promise<void>((r) => this.child.once("exit", () => r()))
|
|
219
|
+
this.child.stdin?.end()
|
|
220
|
+
const t = setTimeout(() => this.child.kill("SIGKILL"), 2000)
|
|
221
|
+
await exited
|
|
222
|
+
clearTimeout(t)
|
|
223
|
+
}
|
|
224
|
+
this.child.stdout?.destroy()
|
|
225
|
+
this.finish()
|
|
226
|
+
}
|
|
227
|
+
}
|
package/src/mcphandler.ts
CHANGED
|
@@ -348,7 +348,7 @@ export function createMcpHandler(toolkitTools: () => Tool[], cfg: McpHandlerConf
|
|
|
348
348
|
/** Validates protocol header + session (stateful). Writes the HTTP error itself on failure. */
|
|
349
349
|
async function checkSession(ctx: McpContext, req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
|
350
350
|
const v = header(req, "MCP-Protocol-Version")
|
|
351
|
-
if (v !== "" && !
|
|
351
|
+
if (v !== "" && !versions.includes(v)) {
|
|
352
352
|
httpError(res, 400, "Bad Request: unsupported MCP-Protocol-Version")
|
|
353
353
|
return false
|
|
354
354
|
}
|
|
@@ -775,8 +775,7 @@ export function createMcpHandler(toolkitTools: () => Tool[], cfg: McpHandlerConf
|
|
|
775
775
|
if (only2026) return true
|
|
776
776
|
if (method === "initialize") return false
|
|
777
777
|
const hv = header(req, "MCP-Protocol-Version")
|
|
778
|
-
if (hv
|
|
779
|
-
if (hv !== "" && versions.includes(hv)) return false
|
|
778
|
+
if (hv !== "" && legacy.includes(hv)) return false
|
|
780
779
|
return method === "server/discover" || header(req, "Mcp-Method") !== "" || (meta !== undefined && META_VERSION in meta)
|
|
781
780
|
}
|
|
782
781
|
|
package/src/mcpserve.ts
CHANGED
|
@@ -16,7 +16,6 @@ import http from "node:http"
|
|
|
16
16
|
import type { AddressInfo } from "node:net"
|
|
17
17
|
import { randomBytes, timingSafeEqual } from "node:crypto"
|
|
18
18
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
|
|
19
|
-
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"
|
|
20
19
|
import { ListToolsRequestSchema, CallToolRequestSchema, McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"
|
|
21
20
|
import type { Tool, ToolSource, ToolResult } from "./types.js"
|
|
22
21
|
import type { ContentPart } from "./content.js"
|
|
@@ -166,6 +165,8 @@ export interface LocalMcpServer {
|
|
|
166
165
|
*/
|
|
167
166
|
export async function serveLocalMcp(tools: Tool[], cfg?: MCPServeConfig): Promise<LocalMcpServer> {
|
|
168
167
|
const exposed = exposedMcpTools(tools, cfg)
|
|
168
|
+
// Loaded on first serve, not at import: the transport pulls in @hono/node-server.
|
|
169
|
+
const { StreamableHTTPServerTransport } = await import("@modelcontextprotocol/sdk/server/streamableHttp.js")
|
|
169
170
|
const token = randomBytes(32).toString("hex")
|
|
170
171
|
const want = Buffer.from("Bearer " + token)
|
|
171
172
|
const server = http.createServer((req, res) => {
|
package/src/serve.ts
CHANGED
|
@@ -18,7 +18,8 @@ import http from "node:http"
|
|
|
18
18
|
import fs from "node:fs"
|
|
19
19
|
import path from "node:path"
|
|
20
20
|
import { randomUUID } from "node:crypto"
|
|
21
|
-
|
|
21
|
+
// The SDK server transport (and the @hono/node-server it pulls in) is loaded only when an MCP
|
|
22
|
+
// profile is actually served, so importing toolnexus does not pay for it.
|
|
22
23
|
import { sanitize, type Tool } from "./types.js"
|
|
23
24
|
import type { SkillInfo } from "./skill.js"
|
|
24
25
|
import type { RunResult } from "./client.js"
|
|
@@ -213,6 +214,9 @@ export async function startA2AServer(opts: StartServerOptions): Promise<ServeHan
|
|
|
213
214
|
const [host, portStr] = splitAddr(opts.addr)
|
|
214
215
|
const store = a2a ? resolveStore(a2a.store) : undefined
|
|
215
216
|
const exposedTools = mcp ? exposedMcpTools(mcpTools ?? [], mcp) : []
|
|
217
|
+
const StreamableHTTPServerTransport = mcp
|
|
218
|
+
? (await import("@modelcontextprotocol/sdk/server/streamableHttp.js")).StreamableHTTPServerTransport
|
|
219
|
+
: undefined
|
|
216
220
|
|
|
217
221
|
const server = http.createServer((req, res) => {
|
|
218
222
|
const rpcRes = (id: unknown, payload: object) => {
|
|
@@ -233,11 +237,18 @@ export async function startA2AServer(opts: StartServerOptions): Promise<ServeHan
|
|
|
233
237
|
// per request (stateless — sessionIdGenerator undefined). Checked first so
|
|
234
238
|
// `/mcp` is never shadowed by the A2A POST handler.
|
|
235
239
|
if (mcp && req.url && (req.url === "/mcp" || req.url.startsWith("/mcp?"))) {
|
|
240
|
+
// Stateless profile: no server-initiated SSE stream exists, so only POST is
|
|
241
|
+
// served. Refuse anything else at once (§7C) instead of holding a GET open.
|
|
242
|
+
if (req.method !== "POST") {
|
|
243
|
+
res.writeHead(405, { Allow: "POST" })
|
|
244
|
+
res.end()
|
|
245
|
+
return
|
|
246
|
+
}
|
|
236
247
|
void (async () => {
|
|
237
248
|
const body = req.method === "POST" ? await readBody(req) : ""
|
|
238
249
|
const parsed = body ? safeJson(body) : undefined
|
|
239
250
|
const mcpServer = buildMcpServer(exposedTools, mcp, onCall)
|
|
240
|
-
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })
|
|
251
|
+
const transport = new StreamableHTTPServerTransport!({ sessionIdGenerator: undefined })
|
|
241
252
|
res.on("close", () => {
|
|
242
253
|
void transport.close()
|
|
243
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 {
|
|
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
|
-
|
|
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)
|