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.
- package/dist/acp.d.ts +10 -2
- package/dist/acp.js +36 -5
- package/dist/classifier.d.ts +10 -0
- package/dist/classifier.js +55 -6
- 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 +5 -1
- package/dist/skill.d.ts +14 -0
- package/dist/skill.js +33 -2
- package/package.json +1 -1
- package/src/acp.ts +59 -7
- package/src/classifier.ts +51 -6
- 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 +6 -2
- package/src/skill.ts +51 -2
package/dist/serve.js
CHANGED
|
@@ -18,7 +18,8 @@ import http from "node:http";
|
|
|
18
18
|
import fs from "node:fs";
|
|
19
19
|
import path from "node:path";
|
|
20
20
|
import { randomUUID } from "node:crypto";
|
|
21
|
-
|
|
21
|
+
// The SDK server transport (and the @hono/node-server it pulls in) is loaded only when an MCP
|
|
22
|
+
// profile is actually served, so importing toolnexus does not pay for it.
|
|
22
23
|
import { sanitize } from "./types.js";
|
|
23
24
|
import { buildMcpServer, exposedMcpTools } from "./mcpserve.js";
|
|
24
25
|
/** Default in-memory store — Tasks live only for the lifetime of the process. */
|
|
@@ -136,6 +137,9 @@ export async function startA2AServer(opts) {
|
|
|
136
137
|
const [host, portStr] = splitAddr(opts.addr);
|
|
137
138
|
const store = a2a ? resolveStore(a2a.store) : undefined;
|
|
138
139
|
const exposedTools = mcp ? exposedMcpTools(mcpTools ?? [], mcp) : [];
|
|
140
|
+
const StreamableHTTPServerTransport = mcp
|
|
141
|
+
? (await import("@modelcontextprotocol/sdk/server/streamableHttp.js")).StreamableHTTPServerTransport
|
|
142
|
+
: undefined;
|
|
139
143
|
const server = http.createServer((req, res) => {
|
|
140
144
|
const rpcRes = (id, payload) => {
|
|
141
145
|
res.writeHead(200, { "content-type": "application/json" });
|
package/dist/skill.d.ts
CHANGED
|
@@ -39,10 +39,22 @@ export interface SkillSkip {
|
|
|
39
39
|
*/
|
|
40
40
|
detail?: string;
|
|
41
41
|
}
|
|
42
|
+
/** Why a KEPT skill carries a warning (SPEC.md §3, #84). The skill still loads unchanged. */
|
|
43
|
+
export type SkillWarningReason = "oversized-description";
|
|
44
|
+
/** Maximum skill `description` length, in Unicode code points, before a warning (SPEC.md §3). */
|
|
45
|
+
export declare const SKILL_DESCRIPTION_MAX_CHARS = 1024;
|
|
46
|
+
export interface SkillWarning {
|
|
47
|
+
location: string;
|
|
48
|
+
reason: SkillWarningReason;
|
|
49
|
+
/** Description length in Unicode CODE POINTS (not bytes, not UTF-16 units). */
|
|
50
|
+
chars: number;
|
|
51
|
+
}
|
|
42
52
|
/** Result of a list-only validate pass (SPEC.md §3, S3). */
|
|
43
53
|
export interface SkillInventory {
|
|
44
54
|
skills: SkillInfo[];
|
|
45
55
|
skipped: SkillSkip[];
|
|
56
|
+
/** Kept skills that load but carry a warning (oversized description), in discovery order. */
|
|
57
|
+
warnings: SkillWarning[];
|
|
46
58
|
}
|
|
47
59
|
/** Options for loadSkills / listSkills (SPEC.md §3, S1/S2/S5). */
|
|
48
60
|
export interface LoadSkillsOptions {
|
|
@@ -65,6 +77,8 @@ export interface SkillSource {
|
|
|
65
77
|
* that stops being reported is indistinguishable from something that was finished.
|
|
66
78
|
*/
|
|
67
79
|
skipped: SkillSkip[];
|
|
80
|
+
/** Exposed (post-allowlist) skills whose description is oversized; never modified (#84). */
|
|
81
|
+
warnings: SkillWarning[];
|
|
68
82
|
}
|
|
69
83
|
/**
|
|
70
84
|
* Discover + validate skills from the same sources loadSkills accepts, returning
|
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
package/src/acp.ts
CHANGED
|
@@ -86,15 +86,28 @@ export interface ACPOptions {
|
|
|
86
86
|
/** One warm ACP connection: one child process, one session, alive across many
|
|
87
87
|
* turns. `.generate` has exactly the shape {@link InProcessOptions.generate}
|
|
88
88
|
* wants, so it plugs straight into {@link createInProcessClient}. */
|
|
89
|
+
/** Per-call options for {@link ACPClient.generate} / {@link ACPClient.generateStream}. */
|
|
90
|
+
export interface ACPGenerateOptions {
|
|
91
|
+
/** Cancels the turn: when it aborts mid-turn the `session/cancel` notification is sent,
|
|
92
|
+
* the turn's reply is awaited (bounded by `requestTimeoutMs`, default 30s) so the next
|
|
93
|
+
* turn never lands on a busy session, and the call rejects with an `AbortError`. The
|
|
94
|
+
* process and session stay warm. */
|
|
95
|
+
signal?: AbortSignal
|
|
96
|
+
}
|
|
97
|
+
|
|
89
98
|
export interface ACPClient {
|
|
90
|
-
generate: (request: InProcessRequest) => Promise<InProcessResponse>
|
|
99
|
+
generate: (request: InProcessRequest, opts?: ACPGenerateOptions) => Promise<InProcessResponse>
|
|
91
100
|
/** {@link generate}, streaming: `emit` fires as each `session/update` arrives —
|
|
92
101
|
* agent_message_chunk → `text`, agent_thought_chunk → `thought` (via "acp"),
|
|
93
102
|
* tool_call → `tool_call` and a completed/failed tool_call_update → `tool_result`
|
|
94
103
|
* (both via "acp": the agent's own tools, reported, never executed by the loop).
|
|
95
104
|
* Shaped for {@link InProcessOptions.generateStream}. The returned content is the
|
|
96
105
|
* message text only. */
|
|
97
|
-
generateStream: (
|
|
106
|
+
generateStream: (
|
|
107
|
+
request: InProcessRequest,
|
|
108
|
+
emit: (event: StreamEvent) => void,
|
|
109
|
+
opts?: ACPGenerateOptions,
|
|
110
|
+
) => Promise<InProcessResponse>
|
|
98
111
|
/** Terminates the agent's process group. Idempotent — a second call is a no-op. */
|
|
99
112
|
close: () => Promise<void>
|
|
100
113
|
/** The agent's raw `initialize` result. */
|
|
@@ -375,7 +388,12 @@ export async function loadACP(opts: ACPOptions): Promise<ACPClient> {
|
|
|
375
388
|
// queue itself never rejects (a failed turn does not poison later ones).
|
|
376
389
|
let turnQueue: Promise<unknown> = Promise.resolve()
|
|
377
390
|
|
|
378
|
-
async function promptOnce(
|
|
391
|
+
async function promptOnce(
|
|
392
|
+
request: InProcessRequest,
|
|
393
|
+
emit?: (event: StreamEvent) => void,
|
|
394
|
+
signal?: AbortSignal,
|
|
395
|
+
): Promise<InProcessResponse> {
|
|
396
|
+
if (signal?.aborted) throw acpAbortError()
|
|
379
397
|
const text = renderPrompt(request)
|
|
380
398
|
const id = `c-${++nextId}`
|
|
381
399
|
|
|
@@ -440,21 +458,48 @@ export async function loadACP(opts: ACPOptions): Promise<ACPClient> {
|
|
|
440
458
|
)
|
|
441
459
|
})
|
|
442
460
|
|
|
461
|
+
let onAbort: (() => void) | undefined
|
|
462
|
+
const cancelled = new Promise<"cancelled">((resolve) => {
|
|
463
|
+
if (!signal) return
|
|
464
|
+
onAbort = () => resolve("cancelled")
|
|
465
|
+
signal.addEventListener("abort", onAbort, { once: true })
|
|
466
|
+
})
|
|
467
|
+
|
|
443
468
|
try {
|
|
444
|
-
const msg = await Promise.race([replyPromise, timeout])
|
|
469
|
+
const msg = await Promise.race([replyPromise, timeout, cancelled])
|
|
470
|
+
if (msg === "cancelled") {
|
|
471
|
+
// ACP: session/cancel is a notification (no id); the agent then ends the turn with
|
|
472
|
+
// stopReason "cancelled". Wait (bounded) for that reply so the session is idle again.
|
|
473
|
+
write({ jsonrpc: "2.0", method: "session/cancel", params: { sessionId } })
|
|
474
|
+
const waitMs = opts.requestTimeoutMs && opts.requestTimeoutMs > 0 ? opts.requestTimeoutMs : 30000
|
|
475
|
+
let wait: NodeJS.Timeout | undefined
|
|
476
|
+
await Promise.race([
|
|
477
|
+
replyPromise,
|
|
478
|
+
new Promise((r) => {
|
|
479
|
+
wait = setTimeout(r, waitMs)
|
|
480
|
+
}),
|
|
481
|
+
])
|
|
482
|
+
clearTimeout(wait)
|
|
483
|
+
throw acpAbortError()
|
|
484
|
+
}
|
|
445
485
|
if (msg.error && msg.error.code === 0) throw new Error(msg.error.message)
|
|
446
486
|
if (msg.error) throw new Error(`toolnexus: ACP error ${msg.error.code}: ${msg.error.message}`)
|
|
447
487
|
return { content }
|
|
448
488
|
} finally {
|
|
449
489
|
clearTimeout(timer)
|
|
490
|
+
if (onAbort) signal!.removeEventListener("abort", onAbort)
|
|
450
491
|
pending.delete(id)
|
|
451
492
|
updateSink = null
|
|
452
493
|
}
|
|
453
494
|
}
|
|
454
495
|
|
|
455
|
-
const generateStream = (
|
|
496
|
+
const generateStream = (
|
|
497
|
+
request: InProcessRequest,
|
|
498
|
+
emit?: (event: StreamEvent) => void,
|
|
499
|
+
callOpts?: ACPGenerateOptions,
|
|
500
|
+
): Promise<InProcessResponse> => {
|
|
456
501
|
if (closed) return Promise.reject(new Error("toolnexus: ACP client is closed"))
|
|
457
|
-
const turn = turnQueue.then(() => promptOnce(request, emit))
|
|
502
|
+
const turn = turnQueue.then(() => promptOnce(request, emit, callOpts?.signal))
|
|
458
503
|
turnQueue = turn.then(
|
|
459
504
|
() => undefined,
|
|
460
505
|
() => undefined,
|
|
@@ -478,7 +523,8 @@ export async function loadACP(opts: ACPOptions): Promise<ACPClient> {
|
|
|
478
523
|
if (t === "late") await Promise.race([childGone, new Promise((r) => setTimeout(r, 1000).unref())])
|
|
479
524
|
}
|
|
480
525
|
|
|
481
|
-
const generate = (request: InProcessRequest): Promise<InProcessResponse> =>
|
|
526
|
+
const generate = (request: InProcessRequest, callOpts?: ACPGenerateOptions): Promise<InProcessResponse> =>
|
|
527
|
+
generateStream(request, undefined, callOpts)
|
|
482
528
|
|
|
483
529
|
const client: ACPClient = { generate, generateStream, close, initializeResult }
|
|
484
530
|
// Non-enumerable, undocumented — internal test hook only, so the public
|
|
@@ -487,6 +533,12 @@ export async function loadACP(opts: ACPOptions): Promise<ACPClient> {
|
|
|
487
533
|
return client
|
|
488
534
|
}
|
|
489
535
|
|
|
536
|
+
function acpAbortError(): Error {
|
|
537
|
+
const e = new Error("toolnexus: ACP turn cancelled")
|
|
538
|
+
e.name = "AbortError"
|
|
539
|
+
return e
|
|
540
|
+
}
|
|
541
|
+
|
|
490
542
|
/** Signals the child's whole process group (POSIX), else the child itself. Never throws. */
|
|
491
543
|
function killGroup(pid: number, signal: NodeJS.Signals): void {
|
|
492
544
|
if (process.platform !== "win32") {
|
package/src/classifier.ts
CHANGED
|
@@ -104,12 +104,35 @@ export const CLASSIFIER_BACKENDS: Readonly<Record<ClassifierBackend, Readonly<Cl
|
|
|
104
104
|
custom: {
|
|
105
105
|
baseUrl: "",
|
|
106
106
|
model: DEFAULT_CLASSIFIER_MODEL,
|
|
107
|
-
apiKeyEnv:
|
|
107
|
+
apiKeyEnv: "",
|
|
108
108
|
wire: "systemone",
|
|
109
109
|
imagesHeader: true,
|
|
110
110
|
},
|
|
111
111
|
})
|
|
112
112
|
|
|
113
|
+
/**
|
|
114
|
+
* The env var whose key may be sent to `baseUrl` (already `${NAME}`-expanded): the backend's own
|
|
115
|
+
* variable, and only when `baseUrl` is that backend's own host over https (OpenRouter also its
|
|
116
|
+
* subdomains). `""` when none may — `custom` has no preset key at all. A key is never sent to a
|
|
117
|
+
* host the caller did not name (#155, SPEC §8B).
|
|
118
|
+
*/
|
|
119
|
+
export function classifierKeyEnv(backend: ClassifierBackend, baseUrl: string): string {
|
|
120
|
+
const p = Object.prototype.hasOwnProperty.call(CLASSIFIER_BACKENDS, backend) ? CLASSIFIER_BACKENDS[backend] : undefined
|
|
121
|
+
if (!p || !p.apiKeyEnv || !p.baseUrl) return ""
|
|
122
|
+
const hostOf = (u: string): string => {
|
|
123
|
+
try {
|
|
124
|
+
const url = new URL(u)
|
|
125
|
+
return url.protocol === "https:" ? url.hostname.toLowerCase() : ""
|
|
126
|
+
} catch {
|
|
127
|
+
return ""
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const h = hostOf(baseUrl)
|
|
131
|
+
const own = hostOf(p.baseUrl)
|
|
132
|
+
if (!h || !own) return ""
|
|
133
|
+
return h === own || (backend === "openrouter" && h.endsWith(".openrouter.ai")) ? p.apiKeyEnv : ""
|
|
134
|
+
}
|
|
135
|
+
|
|
113
136
|
/**
|
|
114
137
|
* Raised BEFORE any network call when the backend table says the backend cannot take what the
|
|
115
138
|
* request carries. Message, exact: `classifier: <backend> (<model>) does not support <feature>`.
|
|
@@ -650,6 +673,9 @@ export class Classifier {
|
|
|
650
673
|
private readonly baseUrl: string
|
|
651
674
|
private readonly model: string
|
|
652
675
|
private readonly apiKeyEnv: string
|
|
676
|
+
/** The caller NAMED apiKeyEnv: sent wherever baseUrl points. Otherwise the preset's own key,
|
|
677
|
+
* and only to its own host (classifierKeyEnv, #155). */
|
|
678
|
+
private readonly explicitKeyEnv: boolean
|
|
653
679
|
private readonly timeoutMs: number
|
|
654
680
|
private readonly backend: ClassifierBackend
|
|
655
681
|
private readonly preset: Readonly<ClassifierBackendPreset>
|
|
@@ -675,6 +701,7 @@ export class Classifier {
|
|
|
675
701
|
this.baseUrl = opts.baseUrl || preset.baseUrl
|
|
676
702
|
this.model = opts.model ?? preset.model
|
|
677
703
|
this.apiKeyEnv = opts.apiKeyEnv ?? preset.apiKeyEnv
|
|
704
|
+
this.explicitKeyEnv = opts.apiKeyEnv !== undefined
|
|
678
705
|
const mismatch = backendMismatch(this.baseUrl, this.model)
|
|
679
706
|
if (mismatch) throw new Error(mismatch)
|
|
680
707
|
this.timeoutMs = opts.timeoutMs ?? DEFAULT_CLASSIFIER_TIMEOUT_MS
|
|
@@ -1019,7 +1046,7 @@ export class Classifier {
|
|
|
1019
1046
|
try {
|
|
1020
1047
|
res = await doFetch(url, {
|
|
1021
1048
|
method: "POST",
|
|
1022
|
-
headers: this.requestHeaders(extraHeaders),
|
|
1049
|
+
headers: this.requestHeaders(extraHeaders, url),
|
|
1023
1050
|
body: payload,
|
|
1024
1051
|
signal: timer.signal,
|
|
1025
1052
|
})
|
|
@@ -1049,9 +1076,10 @@ export class Classifier {
|
|
|
1049
1076
|
}
|
|
1050
1077
|
|
|
1051
1078
|
/** Credential and `${ENV_VAR}` headers resolve AT CALL TIME. Never logged, never returned. */
|
|
1052
|
-
private requestHeaders(extra: Record<string, string
|
|
1079
|
+
private requestHeaders(extra: Record<string, string>, url: string): Record<string, string> {
|
|
1053
1080
|
const headers: Record<string, string> = { "Content-Type": "application/json" }
|
|
1054
|
-
const
|
|
1081
|
+
const keyEnv = this.explicitKeyEnv ? this.apiKeyEnv : classifierKeyEnv(this.backend, url)
|
|
1082
|
+
const key = keyEnv ? process.env[keyEnv] : undefined
|
|
1055
1083
|
if (key) headers["Authorization"] = `Bearer ${key}`
|
|
1056
1084
|
// The image opt-in header goes BEFORE host headers, which win.
|
|
1057
1085
|
for (const [k, v] of Object.entries(extra)) headers[k] = v
|
|
@@ -1341,10 +1369,27 @@ function imageSize(format: string, b: Uint8Array): [number, number] | undefined
|
|
|
1341
1369
|
return undefined
|
|
1342
1370
|
}
|
|
1343
1371
|
|
|
1344
|
-
/**
|
|
1372
|
+
/**
|
|
1373
|
+
* `: <code> <message>` from errors[0] of a failed envelope (SPEC §8B), through the same redaction
|
|
1374
|
+
* and cap as cause(). Cloudflare reports a missing balance, a bad model id and a malformed
|
|
1375
|
+
* request alike as HTTP 200 + success:false, so this is the only thing that tells them apart.
|
|
1376
|
+
* `""` when there is nothing to show.
|
|
1377
|
+
*/
|
|
1378
|
+
function cloudflareCause(env: unknown): string {
|
|
1379
|
+
const errs = env !== null && typeof env === "object" ? (env as any).errors : undefined
|
|
1380
|
+
const e0 = Array.isArray(errs) ? errs[0] : undefined
|
|
1381
|
+
if (e0 === null || typeof e0 !== "object") return ""
|
|
1382
|
+
const parts: string[] = []
|
|
1383
|
+
if ((typeof e0.code === "number" || typeof e0.code === "string") && String(e0.code) !== "") parts.push(String(e0.code))
|
|
1384
|
+
if (typeof e0.message === "string" && e0.message !== "") parts.push(e0.message)
|
|
1385
|
+
const s = capErrorBody(redactErrorBody(parts.join(" ")))
|
|
1386
|
+
return s ? ": " + s : ""
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
/** The System One reply inside a Cloudflare `/ai/run` envelope. Only errors[0] is ever echoed. */
|
|
1345
1390
|
function unwrapCloudflare(env: unknown): unknown {
|
|
1346
1391
|
if (env === null || typeof env !== "object" || Array.isArray(env) || (env as any).success !== true) {
|
|
1347
|
-
throw new Error("classifier: cloudflare: envelope not successful")
|
|
1392
|
+
throw new Error("classifier: cloudflare: envelope not successful" + cloudflareCause(env))
|
|
1348
1393
|
}
|
|
1349
1394
|
let result = (env as any).result
|
|
1350
1395
|
if (result !== null && typeof result === "object" && !Array.isArray(result) && typeof result.state === "string") {
|
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()
|