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/acp.d.ts
CHANGED
|
@@ -48,15 +48,23 @@ export interface ACPOptions {
|
|
|
48
48
|
/** One warm ACP connection: one child process, one session, alive across many
|
|
49
49
|
* turns. `.generate` has exactly the shape {@link InProcessOptions.generate}
|
|
50
50
|
* wants, so it plugs straight into {@link createInProcessClient}. */
|
|
51
|
+
/** Per-call options for {@link ACPClient.generate} / {@link ACPClient.generateStream}. */
|
|
52
|
+
export interface ACPGenerateOptions {
|
|
53
|
+
/** Cancels the turn: when it aborts mid-turn the `session/cancel` notification is sent,
|
|
54
|
+
* the turn's reply is awaited (bounded by `requestTimeoutMs`, default 30s) so the next
|
|
55
|
+
* turn never lands on a busy session, and the call rejects with an `AbortError`. The
|
|
56
|
+
* process and session stay warm. */
|
|
57
|
+
signal?: AbortSignal;
|
|
58
|
+
}
|
|
51
59
|
export interface ACPClient {
|
|
52
|
-
generate: (request: InProcessRequest) => Promise<InProcessResponse>;
|
|
60
|
+
generate: (request: InProcessRequest, opts?: ACPGenerateOptions) => Promise<InProcessResponse>;
|
|
53
61
|
/** {@link generate}, streaming: `emit` fires as each `session/update` arrives —
|
|
54
62
|
* agent_message_chunk → `text`, agent_thought_chunk → `thought` (via "acp"),
|
|
55
63
|
* tool_call → `tool_call` and a completed/failed tool_call_update → `tool_result`
|
|
56
64
|
* (both via "acp": the agent's own tools, reported, never executed by the loop).
|
|
57
65
|
* Shaped for {@link InProcessOptions.generateStream}. The returned content is the
|
|
58
66
|
* message text only. */
|
|
59
|
-
generateStream: (request: InProcessRequest, emit: (event: StreamEvent) => void) => Promise<InProcessResponse>;
|
|
67
|
+
generateStream: (request: InProcessRequest, emit: (event: StreamEvent) => void, opts?: ACPGenerateOptions) => Promise<InProcessResponse>;
|
|
60
68
|
/** Terminates the agent's process group. Idempotent — a second call is a no-op. */
|
|
61
69
|
close: () => Promise<void>;
|
|
62
70
|
/** The agent's raw `initialize` result. */
|
package/dist/acp.js
CHANGED
|
@@ -298,7 +298,9 @@ export async function loadACP(opts) {
|
|
|
298
298
|
// each generate() call attaches after whatever is already queued, and the
|
|
299
299
|
// queue itself never rejects (a failed turn does not poison later ones).
|
|
300
300
|
let turnQueue = Promise.resolve();
|
|
301
|
-
async function promptOnce(request, emit) {
|
|
301
|
+
async function promptOnce(request, emit, signal) {
|
|
302
|
+
if (signal?.aborted)
|
|
303
|
+
throw acpAbortError();
|
|
302
304
|
const text = renderPrompt(request);
|
|
303
305
|
const id = `c-${++nextId}`;
|
|
304
306
|
let content = "";
|
|
@@ -361,8 +363,30 @@ export async function loadACP(opts) {
|
|
|
361
363
|
const timeout = new Promise((_, reject) => {
|
|
362
364
|
timer = setTimeout(() => reject(new Error(`toolnexus: ACP turn timed out after ${permissionTimeoutMs}ms`)), permissionTimeoutMs);
|
|
363
365
|
});
|
|
366
|
+
let onAbort;
|
|
367
|
+
const cancelled = new Promise((resolve) => {
|
|
368
|
+
if (!signal)
|
|
369
|
+
return;
|
|
370
|
+
onAbort = () => resolve("cancelled");
|
|
371
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
372
|
+
});
|
|
364
373
|
try {
|
|
365
|
-
const msg = await Promise.race([replyPromise, timeout]);
|
|
374
|
+
const msg = await Promise.race([replyPromise, timeout, cancelled]);
|
|
375
|
+
if (msg === "cancelled") {
|
|
376
|
+
// ACP: session/cancel is a notification (no id); the agent then ends the turn with
|
|
377
|
+
// stopReason "cancelled". Wait (bounded) for that reply so the session is idle again.
|
|
378
|
+
write({ jsonrpc: "2.0", method: "session/cancel", params: { sessionId } });
|
|
379
|
+
const waitMs = opts.requestTimeoutMs && opts.requestTimeoutMs > 0 ? opts.requestTimeoutMs : 30000;
|
|
380
|
+
let wait;
|
|
381
|
+
await Promise.race([
|
|
382
|
+
replyPromise,
|
|
383
|
+
new Promise((r) => {
|
|
384
|
+
wait = setTimeout(r, waitMs);
|
|
385
|
+
}),
|
|
386
|
+
]);
|
|
387
|
+
clearTimeout(wait);
|
|
388
|
+
throw acpAbortError();
|
|
389
|
+
}
|
|
366
390
|
if (msg.error && msg.error.code === 0)
|
|
367
391
|
throw new Error(msg.error.message);
|
|
368
392
|
if (msg.error)
|
|
@@ -371,14 +395,16 @@ export async function loadACP(opts) {
|
|
|
371
395
|
}
|
|
372
396
|
finally {
|
|
373
397
|
clearTimeout(timer);
|
|
398
|
+
if (onAbort)
|
|
399
|
+
signal.removeEventListener("abort", onAbort);
|
|
374
400
|
pending.delete(id);
|
|
375
401
|
updateSink = null;
|
|
376
402
|
}
|
|
377
403
|
}
|
|
378
|
-
const generateStream = (request, emit) => {
|
|
404
|
+
const generateStream = (request, emit, callOpts) => {
|
|
379
405
|
if (closed)
|
|
380
406
|
return Promise.reject(new Error("toolnexus: ACP client is closed"));
|
|
381
|
-
const turn = turnQueue.then(() => promptOnce(request, emit));
|
|
407
|
+
const turn = turnQueue.then(() => promptOnce(request, emit, callOpts?.signal));
|
|
382
408
|
turnQueue = turn.then(() => undefined, () => undefined);
|
|
383
409
|
return turn;
|
|
384
410
|
};
|
|
@@ -400,13 +426,18 @@ export async function loadACP(opts) {
|
|
|
400
426
|
if (t === "late")
|
|
401
427
|
await Promise.race([childGone, new Promise((r) => setTimeout(r, 1000).unref())]);
|
|
402
428
|
}
|
|
403
|
-
const generate = (request) => generateStream(request);
|
|
429
|
+
const generate = (request, callOpts) => generateStream(request, undefined, callOpts);
|
|
404
430
|
const client = { generate, generateStream, close, initializeResult };
|
|
405
431
|
// Non-enumerable, undocumented — internal test hook only, so the public
|
|
406
432
|
// ACPClient surface stays exactly {generate, generateStream, close}.
|
|
407
433
|
Object.defineProperty(client, "_pid", { value: child.pid, enumerable: false });
|
|
408
434
|
return client;
|
|
409
435
|
}
|
|
436
|
+
function acpAbortError() {
|
|
437
|
+
const e = new Error("toolnexus: ACP turn cancelled");
|
|
438
|
+
e.name = "AbortError";
|
|
439
|
+
return e;
|
|
440
|
+
}
|
|
410
441
|
/** Signals the child's whole process group (POSIX), else the child itself. Never throws. */
|
|
411
442
|
function killGroup(pid, signal) {
|
|
412
443
|
if (process.platform !== "win32") {
|
package/dist/classifier.d.ts
CHANGED
|
@@ -47,6 +47,13 @@ export declare const CLASSIFIER_REQUEST_IMAGE_BYTES: number;
|
|
|
47
47
|
* Cloudflare-Jev images stay false until a live probe proves them.
|
|
48
48
|
*/
|
|
49
49
|
export declare const CLASSIFIER_BACKENDS: Readonly<Record<ClassifierBackend, Readonly<ClassifierBackendPreset>>>;
|
|
50
|
+
/**
|
|
51
|
+
* The env var whose key may be sent to `baseUrl` (already `${NAME}`-expanded): the backend's own
|
|
52
|
+
* variable, and only when `baseUrl` is that backend's own host over https (OpenRouter also its
|
|
53
|
+
* subdomains). `""` when none may — `custom` has no preset key at all. A key is never sent to a
|
|
54
|
+
* host the caller did not name (#155, SPEC §8B).
|
|
55
|
+
*/
|
|
56
|
+
export declare function classifierKeyEnv(backend: ClassifierBackend, baseUrl: string): string;
|
|
50
57
|
/**
|
|
51
58
|
* Raised BEFORE any network call when the backend table says the backend cannot take what the
|
|
52
59
|
* request carries. Message, exact: `classifier: <backend> (<model>) does not support <feature>`.
|
|
@@ -377,6 +384,9 @@ export declare class Classifier {
|
|
|
377
384
|
private readonly baseUrl;
|
|
378
385
|
private readonly model;
|
|
379
386
|
private readonly apiKeyEnv;
|
|
387
|
+
/** The caller NAMED apiKeyEnv: sent wherever baseUrl points. Otherwise the preset's own key,
|
|
388
|
+
* and only to its own host (classifierKeyEnv, #155). */
|
|
389
|
+
private readonly explicitKeyEnv;
|
|
380
390
|
private readonly timeoutMs;
|
|
381
391
|
private readonly backend;
|
|
382
392
|
private readonly preset;
|
package/dist/classifier.js
CHANGED
|
@@ -71,11 +71,36 @@ export const CLASSIFIER_BACKENDS = Object.freeze({
|
|
|
71
71
|
custom: {
|
|
72
72
|
baseUrl: "",
|
|
73
73
|
model: DEFAULT_CLASSIFIER_MODEL,
|
|
74
|
-
apiKeyEnv:
|
|
74
|
+
apiKeyEnv: "",
|
|
75
75
|
wire: "systemone",
|
|
76
76
|
imagesHeader: true,
|
|
77
77
|
},
|
|
78
78
|
});
|
|
79
|
+
/**
|
|
80
|
+
* The env var whose key may be sent to `baseUrl` (already `${NAME}`-expanded): the backend's own
|
|
81
|
+
* variable, and only when `baseUrl` is that backend's own host over https (OpenRouter also its
|
|
82
|
+
* subdomains). `""` when none may — `custom` has no preset key at all. A key is never sent to a
|
|
83
|
+
* host the caller did not name (#155, SPEC §8B).
|
|
84
|
+
*/
|
|
85
|
+
export function classifierKeyEnv(backend, baseUrl) {
|
|
86
|
+
const p = Object.prototype.hasOwnProperty.call(CLASSIFIER_BACKENDS, backend) ? CLASSIFIER_BACKENDS[backend] : undefined;
|
|
87
|
+
if (!p || !p.apiKeyEnv || !p.baseUrl)
|
|
88
|
+
return "";
|
|
89
|
+
const hostOf = (u) => {
|
|
90
|
+
try {
|
|
91
|
+
const url = new URL(u);
|
|
92
|
+
return url.protocol === "https:" ? url.hostname.toLowerCase() : "";
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return "";
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
const h = hostOf(baseUrl);
|
|
99
|
+
const own = hostOf(p.baseUrl);
|
|
100
|
+
if (!h || !own)
|
|
101
|
+
return "";
|
|
102
|
+
return h === own || (backend === "openrouter" && h.endsWith(".openrouter.ai")) ? p.apiKeyEnv : "";
|
|
103
|
+
}
|
|
79
104
|
/**
|
|
80
105
|
* Raised BEFORE any network call when the backend table says the backend cannot take what the
|
|
81
106
|
* request carries. Message, exact: `classifier: <backend> (<model>) does not support <feature>`.
|
|
@@ -380,6 +405,9 @@ export class Classifier {
|
|
|
380
405
|
baseUrl;
|
|
381
406
|
model;
|
|
382
407
|
apiKeyEnv;
|
|
408
|
+
/** The caller NAMED apiKeyEnv: sent wherever baseUrl points. Otherwise the preset's own key,
|
|
409
|
+
* and only to its own host (classifierKeyEnv, #155). */
|
|
410
|
+
explicitKeyEnv;
|
|
383
411
|
timeoutMs;
|
|
384
412
|
backend;
|
|
385
413
|
preset;
|
|
@@ -403,6 +431,7 @@ export class Classifier {
|
|
|
403
431
|
this.baseUrl = opts.baseUrl || preset.baseUrl;
|
|
404
432
|
this.model = opts.model ?? preset.model;
|
|
405
433
|
this.apiKeyEnv = opts.apiKeyEnv ?? preset.apiKeyEnv;
|
|
434
|
+
this.explicitKeyEnv = opts.apiKeyEnv !== undefined;
|
|
406
435
|
const mismatch = backendMismatch(this.baseUrl, this.model);
|
|
407
436
|
if (mismatch)
|
|
408
437
|
throw new Error(mismatch);
|
|
@@ -745,7 +774,7 @@ export class Classifier {
|
|
|
745
774
|
try {
|
|
746
775
|
res = await doFetch(url, {
|
|
747
776
|
method: "POST",
|
|
748
|
-
headers: this.requestHeaders(extraHeaders),
|
|
777
|
+
headers: this.requestHeaders(extraHeaders, url),
|
|
749
778
|
body: payload,
|
|
750
779
|
signal: timer.signal,
|
|
751
780
|
});
|
|
@@ -781,9 +810,10 @@ export class Classifier {
|
|
|
781
810
|
}
|
|
782
811
|
}
|
|
783
812
|
/** Credential and `${ENV_VAR}` headers resolve AT CALL TIME. Never logged, never returned. */
|
|
784
|
-
requestHeaders(extra
|
|
813
|
+
requestHeaders(extra, url) {
|
|
785
814
|
const headers = { "Content-Type": "application/json" };
|
|
786
|
-
const
|
|
815
|
+
const keyEnv = this.explicitKeyEnv ? this.apiKeyEnv : classifierKeyEnv(this.backend, url);
|
|
816
|
+
const key = keyEnv ? process.env[keyEnv] : undefined;
|
|
787
817
|
if (key)
|
|
788
818
|
headers["Authorization"] = `Bearer ${key}`;
|
|
789
819
|
// The image opt-in header goes BEFORE host headers, which win.
|
|
@@ -1057,10 +1087,29 @@ function imageSize(format, b) {
|
|
|
1057
1087
|
}
|
|
1058
1088
|
return undefined;
|
|
1059
1089
|
}
|
|
1060
|
-
/**
|
|
1090
|
+
/**
|
|
1091
|
+
* `: <code> <message>` from errors[0] of a failed envelope (SPEC §8B), through the same redaction
|
|
1092
|
+
* and cap as cause(). Cloudflare reports a missing balance, a bad model id and a malformed
|
|
1093
|
+
* request alike as HTTP 200 + success:false, so this is the only thing that tells them apart.
|
|
1094
|
+
* `""` when there is nothing to show.
|
|
1095
|
+
*/
|
|
1096
|
+
function cloudflareCause(env) {
|
|
1097
|
+
const errs = env !== null && typeof env === "object" ? env.errors : undefined;
|
|
1098
|
+
const e0 = Array.isArray(errs) ? errs[0] : undefined;
|
|
1099
|
+
if (e0 === null || typeof e0 !== "object")
|
|
1100
|
+
return "";
|
|
1101
|
+
const parts = [];
|
|
1102
|
+
if ((typeof e0.code === "number" || typeof e0.code === "string") && String(e0.code) !== "")
|
|
1103
|
+
parts.push(String(e0.code));
|
|
1104
|
+
if (typeof e0.message === "string" && e0.message !== "")
|
|
1105
|
+
parts.push(e0.message);
|
|
1106
|
+
const s = capErrorBody(redactErrorBody(parts.join(" ")));
|
|
1107
|
+
return s ? ": " + s : "";
|
|
1108
|
+
}
|
|
1109
|
+
/** The System One reply inside a Cloudflare `/ai/run` envelope. Only errors[0] is ever echoed. */
|
|
1061
1110
|
function unwrapCloudflare(env) {
|
|
1062
1111
|
if (env === null || typeof env !== "object" || Array.isArray(env) || env.success !== true) {
|
|
1063
|
-
throw new Error("classifier: cloudflare: envelope not successful");
|
|
1112
|
+
throw new Error("classifier: cloudflare: envelope not successful" + cloudflareCause(env));
|
|
1064
1113
|
}
|
|
1065
1114
|
let result = env.result;
|
|
1066
1115
|
if (result !== null && typeof result === "object" && !Array.isArray(result) && typeof result.state === "string") {
|
package/dist/mcp.js
CHANGED
|
@@ -6,14 +6,16 @@
|
|
|
6
6
|
import { readFileSync } from "node:fs";
|
|
7
7
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
8
8
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
9
|
-
import {
|
|
10
|
-
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
9
|
+
import { AdoptedStdioTransport } from "./mcp2026/stdio.js";
|
|
11
10
|
import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
12
11
|
import { sanitize } from "./types.js";
|
|
13
12
|
import { summarizePart } from "./content.js";
|
|
14
|
-
import { probe, newOAuth, defaultTokenStore } from "./mcp2026/source.js";
|
|
15
13
|
import { PendingError } from "./mcp2026/auth.js";
|
|
16
14
|
export { Token, MemoryTokenStore } from "./mcp2026/auth.js";
|
|
15
|
+
// Loaded on first use, not at import: the 2026 client is only needed once a config is loaded,
|
|
16
|
+
// and the SDK's remote transports (streamable-HTTP, SSE) only when a remote server falls back.
|
|
17
|
+
let mcp2026;
|
|
18
|
+
const loadMcp2026 = () => (mcp2026 ??= import("./mcp2026/source.js"));
|
|
17
19
|
// ---------------------------------------------------------------------------
|
|
18
20
|
// MCP elicitation bridge (§10). A server can ask US for input mid-`tools/call`
|
|
19
21
|
// (a reverse-request). We map it onto the one `waitFor`: form mode → kind:"input",
|
|
@@ -410,6 +412,10 @@ async function connectTransport(client, cfg, auth) {
|
|
|
410
412
|
const expanded = expandEnvHeaders(cfg.headers);
|
|
411
413
|
const requestInit = expanded ? { headers: expanded } : undefined;
|
|
412
414
|
const fetch = auth ? bearerFetch(cfg.url, auth) : undefined;
|
|
415
|
+
const [{ StreamableHTTPClientTransport }, { SSEClientTransport }] = await Promise.all([
|
|
416
|
+
import("@modelcontextprotocol/sdk/client/streamableHttp.js"),
|
|
417
|
+
import("@modelcontextprotocol/sdk/client/sse.js"),
|
|
418
|
+
]);
|
|
413
419
|
try {
|
|
414
420
|
await client.connect(new StreamableHTTPClientTransport(url, { requestInit, ...(fetch ? { fetch } : {}) }));
|
|
415
421
|
}
|
|
@@ -469,6 +475,7 @@ function bearerFetch(resource, a) {
|
|
|
469
475
|
* path, with the OAuth bearer attached for remote servers. Throws ⇒ the server failed.
|
|
470
476
|
*/
|
|
471
477
|
async function connectServer(name, cfg, timeout, opts, store, sdkClient, onSdk) {
|
|
478
|
+
const { newOAuth, probe } = await loadMcp2026();
|
|
472
479
|
const o = { waitFor: opts.waitFor, tokenStore: store, openUrl: opts.openUrl, signal: opts.signal, disableTools: opts.disableTools };
|
|
473
480
|
const remote = isRemote(cfg);
|
|
474
481
|
const oauth = remote ? newOAuth(cfg.auth, o, store) : undefined;
|
|
@@ -485,7 +492,10 @@ async function connectServer(name, cfg, timeout, opts, store, sdkClient, onSdk)
|
|
|
485
492
|
return p;
|
|
486
493
|
const client = sdkClient();
|
|
487
494
|
onSdk(client);
|
|
488
|
-
|
|
495
|
+
const connecting = p.reuse
|
|
496
|
+
? client.connect(new AdoptedStdioTransport(p.reuse.child, p.reuse.leftover)) // §2.2a: same child, no respawn
|
|
497
|
+
: connectTransport(client, cfg, oauth ? { oauth, waitFor: opts.waitFor } : undefined);
|
|
498
|
+
await raceTimeout(connecting, timeout, opts.signal);
|
|
489
499
|
const defs = await raceTimeout(paginateTools(client, timeout), timeout, opts.signal);
|
|
490
500
|
return { kind: "sdk", client, defs };
|
|
491
501
|
}
|
|
@@ -499,7 +509,7 @@ export async function loadMcp(input, opts) {
|
|
|
499
509
|
const clients = [];
|
|
500
510
|
const servers = new Map();
|
|
501
511
|
const skills = [];
|
|
502
|
-
const store = defaultTokenStore({ tokenStore: opts?.tokenStore });
|
|
512
|
+
const store = (await loadMcp2026()).defaultTokenStore({ tokenStore: opts?.tokenStore });
|
|
503
513
|
const waitFor = opts?.waitFor;
|
|
504
514
|
const signal = opts?.signal;
|
|
505
515
|
const onServer = opts?.onServer;
|
|
@@ -590,7 +600,7 @@ export async function listMcpTools(input, opts) {
|
|
|
590
600
|
const toolsByServer = {};
|
|
591
601
|
const status = {};
|
|
592
602
|
const signal = opts?.signal;
|
|
593
|
-
const store = defaultTokenStore({ tokenStore: opts?.tokenStore });
|
|
603
|
+
const store = (await loadMcp2026()).defaultTokenStore({ tokenStore: opts?.tokenStore });
|
|
594
604
|
await Promise.all(Object.entries(config).map(async ([name, cfg]) => {
|
|
595
605
|
if (!isEnabled(cfg)) {
|
|
596
606
|
status[name] = "disabled";
|
package/dist/mcp2026/source.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Mcp2026Client } from "./client.js";
|
|
2
2
|
import { type Obj } from "./protocol.js";
|
|
3
3
|
import { OAuth, type TokenStore } from "./auth.js";
|
|
4
|
+
import type { ChildProcess } from "node:child_process";
|
|
4
5
|
import type { Answer, Request, Tool, ToolContext, ToolResult } from "../types.js";
|
|
5
6
|
import type { SkillDef } from "../skill.js";
|
|
6
7
|
export type WaitFor = (request: Request) => Promise<Answer>;
|
|
@@ -33,6 +34,10 @@ export type Probe = {
|
|
|
33
34
|
server: ModernServer;
|
|
34
35
|
} | {
|
|
35
36
|
kind: "fallback";
|
|
37
|
+
reuse?: {
|
|
38
|
+
child: ChildProcess;
|
|
39
|
+
leftover: Buffer;
|
|
40
|
+
};
|
|
36
41
|
};
|
|
37
42
|
export interface ProbeTarget {
|
|
38
43
|
name: string;
|
package/dist/mcp2026/source.js
CHANGED
|
@@ -78,12 +78,15 @@ function supports2026(r) {
|
|
|
78
78
|
/** Discover first (§2.2). Resolves `modern` (tools listed) or `fallback`; throws on a load failure. */
|
|
79
79
|
export async function probe(t, o) {
|
|
80
80
|
let client;
|
|
81
|
+
let stdio;
|
|
81
82
|
if (t.remote) {
|
|
82
83
|
client = new Mcp2026Client(t.remote.url, { headers: t.remote.headers, auth: t.remote.oauth, timeoutMs: t.timeout });
|
|
83
84
|
}
|
|
84
85
|
else {
|
|
85
|
-
|
|
86
|
-
|
|
86
|
+
stdio = await spawnStdio(t.local.argv, t.local.env, t.local.cwd);
|
|
87
|
+
// Stop parsing right after the discover reply so an old server's child can be handed over.
|
|
88
|
+
stdio.holdAfterNextResponse();
|
|
89
|
+
client = new Mcp2026Client("", { transport: stdio, timeoutMs: t.timeout });
|
|
87
90
|
}
|
|
88
91
|
const oauth = t.remote?.oauth;
|
|
89
92
|
let disc;
|
|
@@ -94,6 +97,9 @@ export async function probe(t, o) {
|
|
|
94
97
|
}
|
|
95
98
|
catch (e) {
|
|
96
99
|
const stdioTimeout = !!t.local && e instanceof Error && e.message === "stdio request timed out";
|
|
100
|
+
// §2.2a: a fast JSON-RPC error over stdio ⇒ the SAME child continues into initialize.
|
|
101
|
+
if (stdio && e instanceof RpcError && !o.signal?.aborted)
|
|
102
|
+
return { kind: "fallback", reuse: stdio.detach() };
|
|
97
103
|
await client.close().catch(() => { });
|
|
98
104
|
if (o.signal?.aborted)
|
|
99
105
|
throw e;
|
|
@@ -104,9 +110,12 @@ export async function probe(t, o) {
|
|
|
104
110
|
throw e;
|
|
105
111
|
}
|
|
106
112
|
if (!supports2026(disc)) {
|
|
113
|
+
if (stdio)
|
|
114
|
+
return { kind: "fallback", reuse: stdio.detach() };
|
|
107
115
|
await client.close().catch(() => { });
|
|
108
116
|
return { kind: "fallback" };
|
|
109
117
|
}
|
|
118
|
+
stdio?.release();
|
|
110
119
|
try {
|
|
111
120
|
const server = new ModernServer(t, client, disc, o);
|
|
112
121
|
await server.load();
|
package/dist/mcp2026/stdio.d.ts
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
import { type ChildProcess } from "node:child_process";
|
|
7
7
|
import type { Transport } from "./client.js";
|
|
8
8
|
import type { Obj } from "./protocol.js";
|
|
9
|
+
import type { Transport as SdkTransport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
|
10
|
+
import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js";
|
|
9
11
|
/** The child exited / closed stdout before answering. */
|
|
10
12
|
export declare class StdioClosedError extends Error {
|
|
11
13
|
}
|
|
@@ -14,7 +16,28 @@ export declare class StdioTransport implements Transport {
|
|
|
14
16
|
private err?;
|
|
15
17
|
private done;
|
|
16
18
|
private child;
|
|
19
|
+
private buf;
|
|
20
|
+
private onData;
|
|
21
|
+
private onEnd;
|
|
22
|
+
private detached;
|
|
23
|
+
private hold;
|
|
24
|
+
private held;
|
|
17
25
|
constructor(child: ChildProcess);
|
|
26
|
+
private drain;
|
|
27
|
+
/** Probe mode: after the next delivered response, stop parsing (bytes stay buffered for detach). */
|
|
28
|
+
holdAfterNextResponse(): void;
|
|
29
|
+
/** Leave probe mode and parse whatever was held. */
|
|
30
|
+
release(): void;
|
|
31
|
+
private onLine;
|
|
32
|
+
/**
|
|
33
|
+
* §2.2a handoff: stop reading stdout (listeners removed, stream paused — no read left pending)
|
|
34
|
+
* and give the live child plus any bytes already read past the last delivered line to the
|
|
35
|
+
* frozen path. The transport is unusable afterwards; the child is NOT killed.
|
|
36
|
+
*/
|
|
37
|
+
detach(): {
|
|
38
|
+
child: ChildProcess;
|
|
39
|
+
leftover: Buffer;
|
|
40
|
+
};
|
|
18
41
|
/** Writes one line and waits for the response with the same id. Never retried. */
|
|
19
42
|
roundTrip(id: number, body: string, timeoutMs: number, signal?: AbortSignal): Promise<Obj>;
|
|
20
43
|
/** Close stdin, then SIGKILL after 2 s if the child is still alive. */
|
|
@@ -22,3 +45,23 @@ export declare class StdioTransport implements Transport {
|
|
|
22
45
|
}
|
|
23
46
|
/** Spawns argv (no shell) and returns its transport; rejects if the spawn itself fails. */
|
|
24
47
|
export declare function spawnStdio(argv: string[], env: NodeJS.ProcessEnv, cwd?: string): Promise<StdioTransport>;
|
|
48
|
+
/**
|
|
49
|
+
* §2.2a: the frozen SDK path over a child the probe already started (StdioClientTransport always
|
|
50
|
+
* spawns). Takes the detached probe's stdin/stdout plus any bytes it had read but not parsed.
|
|
51
|
+
*/
|
|
52
|
+
export declare class AdoptedStdioTransport implements SdkTransport {
|
|
53
|
+
private child;
|
|
54
|
+
private leftover;
|
|
55
|
+
onclose?: () => void;
|
|
56
|
+
onerror?: (error: Error) => void;
|
|
57
|
+
onmessage?: (message: JSONRPCMessage) => void;
|
|
58
|
+
private rb;
|
|
59
|
+
private closed;
|
|
60
|
+
constructor(child: ChildProcess, leftover: Buffer);
|
|
61
|
+
start(): Promise<void>;
|
|
62
|
+
private feed;
|
|
63
|
+
send(message: JSONRPCMessage): Promise<void>;
|
|
64
|
+
private finish;
|
|
65
|
+
/** Close stdin, then SIGKILL after 2 s if the child is still alive. */
|
|
66
|
+
close(): Promise<void>;
|
|
67
|
+
}
|
package/dist/mcp2026/stdio.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* pending call instead of hanging. No headers, no auth.
|
|
5
5
|
*/
|
|
6
6
|
import { spawn } from "node:child_process";
|
|
7
|
-
import {
|
|
7
|
+
import { ReadBuffer, serializeMessage } from "@modelcontextprotocol/sdk/shared/stdio.js";
|
|
8
8
|
/** The child exited / closed stdout before answering. */
|
|
9
9
|
export class StdioClosedError extends Error {
|
|
10
10
|
}
|
|
@@ -13,33 +13,86 @@ export class StdioTransport {
|
|
|
13
13
|
err;
|
|
14
14
|
done;
|
|
15
15
|
child;
|
|
16
|
+
buf = Buffer.alloc(0);
|
|
17
|
+
onData;
|
|
18
|
+
onEnd;
|
|
19
|
+
detached = false;
|
|
20
|
+
hold = false;
|
|
21
|
+
held = false;
|
|
16
22
|
constructor(child) {
|
|
17
23
|
this.child = child;
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
27
|
-
if (!m || typeof m !== "object" || m.method !== undefined || m.id === undefined || m.id === null)
|
|
28
|
-
return; // not a response
|
|
29
|
-
const key = JSON.stringify(m.id);
|
|
30
|
-
const f = this.pending.get(key);
|
|
31
|
-
this.pending.delete(key);
|
|
32
|
-
f?.(m);
|
|
33
|
-
});
|
|
34
|
-
this.done = new Promise((r) => rl.once("close", () => {
|
|
24
|
+
// Line-exact own reader (no readline): detach() can stop it and hand over unread bytes.
|
|
25
|
+
let closed;
|
|
26
|
+
this.done = new Promise((r) => (closed = r));
|
|
27
|
+
this.onData = (chunk) => {
|
|
28
|
+
this.buf = this.buf.length ? Buffer.concat([this.buf, chunk]) : chunk;
|
|
29
|
+
this.drain();
|
|
30
|
+
};
|
|
31
|
+
this.onEnd = () => {
|
|
35
32
|
this.err ??= new StdioClosedError("stdio server closed its stdout");
|
|
36
33
|
for (const f of this.pending.values())
|
|
37
34
|
f({ __closed: true });
|
|
38
35
|
this.pending.clear();
|
|
39
|
-
|
|
40
|
-
}
|
|
36
|
+
closed();
|
|
37
|
+
};
|
|
38
|
+
const out = child.stdout;
|
|
39
|
+
out.on("data", this.onData);
|
|
40
|
+
out.once("end", this.onEnd);
|
|
41
|
+
out.once("close", this.onEnd);
|
|
41
42
|
child.stdin?.on("error", () => { }); // EPIPE after the server died surfaces as a closed stdout
|
|
42
43
|
}
|
|
44
|
+
drain() {
|
|
45
|
+
let nl;
|
|
46
|
+
while (!this.detached && !this.held && (nl = this.buf.indexOf(10)) >= 0) {
|
|
47
|
+
const line = this.buf.subarray(0, nl).toString("utf8").replace(/\r$/, "");
|
|
48
|
+
this.buf = this.buf.subarray(nl + 1);
|
|
49
|
+
this.onLine(line);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Probe mode: after the next delivered response, stop parsing (bytes stay buffered for detach). */
|
|
53
|
+
holdAfterNextResponse() {
|
|
54
|
+
this.hold = true;
|
|
55
|
+
}
|
|
56
|
+
/** Leave probe mode and parse whatever was held. */
|
|
57
|
+
release() {
|
|
58
|
+
this.hold = false;
|
|
59
|
+
this.held = false;
|
|
60
|
+
this.drain();
|
|
61
|
+
}
|
|
62
|
+
onLine(line) {
|
|
63
|
+
let m;
|
|
64
|
+
try {
|
|
65
|
+
m = JSON.parse(line);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return; // noise
|
|
69
|
+
}
|
|
70
|
+
if (!m || typeof m !== "object" || m.method !== undefined || m.id === undefined || m.id === null)
|
|
71
|
+
return; // not a response
|
|
72
|
+
const key = JSON.stringify(m.id);
|
|
73
|
+
const f = this.pending.get(key);
|
|
74
|
+
this.pending.delete(key);
|
|
75
|
+
if (f && this.hold)
|
|
76
|
+
this.held = true;
|
|
77
|
+
f?.(m);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* §2.2a handoff: stop reading stdout (listeners removed, stream paused — no read left pending)
|
|
81
|
+
* and give the live child plus any bytes already read past the last delivered line to the
|
|
82
|
+
* frozen path. The transport is unusable afterwards; the child is NOT killed.
|
|
83
|
+
*/
|
|
84
|
+
detach() {
|
|
85
|
+
this.detached = true;
|
|
86
|
+
const out = this.child.stdout;
|
|
87
|
+
out.off("data", this.onData);
|
|
88
|
+
out.off("end", this.onEnd);
|
|
89
|
+
out.off("close", this.onEnd);
|
|
90
|
+
out.pause();
|
|
91
|
+
this.err ??= new StdioClosedError("stdio transport detached");
|
|
92
|
+
const leftover = this.buf;
|
|
93
|
+
this.buf = Buffer.alloc(0);
|
|
94
|
+
return { child: this.child, leftover };
|
|
95
|
+
}
|
|
43
96
|
/** Writes one line and waits for the response with the same id. Never retried. */
|
|
44
97
|
roundTrip(id, body, timeoutMs, signal) {
|
|
45
98
|
if (this.err)
|
|
@@ -97,3 +150,74 @@ export function spawnStdio(argv, env, cwd) {
|
|
|
97
150
|
child.once("spawn", () => resolve(new StdioTransport(child)));
|
|
98
151
|
});
|
|
99
152
|
}
|
|
153
|
+
/**
|
|
154
|
+
* §2.2a: the frozen SDK path over a child the probe already started (StdioClientTransport always
|
|
155
|
+
* spawns). Takes the detached probe's stdin/stdout plus any bytes it had read but not parsed.
|
|
156
|
+
*/
|
|
157
|
+
export class AdoptedStdioTransport {
|
|
158
|
+
child;
|
|
159
|
+
leftover;
|
|
160
|
+
onclose;
|
|
161
|
+
onerror;
|
|
162
|
+
onmessage;
|
|
163
|
+
rb = new ReadBuffer();
|
|
164
|
+
closed = false;
|
|
165
|
+
constructor(child, leftover) {
|
|
166
|
+
this.child = child;
|
|
167
|
+
this.leftover = leftover;
|
|
168
|
+
}
|
|
169
|
+
async start() {
|
|
170
|
+
const out = this.child.stdout;
|
|
171
|
+
out.on("data", (chunk) => this.feed(chunk));
|
|
172
|
+
out.on("error", (e) => this.onerror?.(e));
|
|
173
|
+
this.child.stdin?.on("error", (e) => this.onerror?.(e));
|
|
174
|
+
this.child.once("close", () => this.finish());
|
|
175
|
+
if (this.leftover.length)
|
|
176
|
+
this.feed(this.leftover);
|
|
177
|
+
this.leftover = Buffer.alloc(0);
|
|
178
|
+
out.resume();
|
|
179
|
+
}
|
|
180
|
+
feed(chunk) {
|
|
181
|
+
this.rb.append(chunk);
|
|
182
|
+
for (;;) {
|
|
183
|
+
let m;
|
|
184
|
+
try {
|
|
185
|
+
m = this.rb.readMessage();
|
|
186
|
+
}
|
|
187
|
+
catch (e) {
|
|
188
|
+
this.onerror?.(e);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (m === null)
|
|
192
|
+
break;
|
|
193
|
+
this.onmessage?.(m);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
send(message) {
|
|
197
|
+
return new Promise((resolve) => {
|
|
198
|
+
if (!this.child.stdin?.write(serializeMessage(message)))
|
|
199
|
+
this.child.stdin?.once("drain", resolve);
|
|
200
|
+
else
|
|
201
|
+
resolve();
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
finish() {
|
|
205
|
+
if (this.closed)
|
|
206
|
+
return;
|
|
207
|
+
this.closed = true;
|
|
208
|
+
this.rb.clear();
|
|
209
|
+
this.onclose?.();
|
|
210
|
+
}
|
|
211
|
+
/** Close stdin, then SIGKILL after 2 s if the child is still alive. */
|
|
212
|
+
async close() {
|
|
213
|
+
if (this.child.exitCode === null && this.child.signalCode === null) {
|
|
214
|
+
const exited = new Promise((r) => this.child.once("exit", () => r()));
|
|
215
|
+
this.child.stdin?.end();
|
|
216
|
+
const t = setTimeout(() => this.child.kill("SIGKILL"), 2000);
|
|
217
|
+
await exited;
|
|
218
|
+
clearTimeout(t);
|
|
219
|
+
}
|
|
220
|
+
this.child.stdout?.destroy();
|
|
221
|
+
this.finish();
|
|
222
|
+
}
|
|
223
|
+
}
|
package/dist/mcphandler.js
CHANGED
|
@@ -196,7 +196,7 @@ export function createMcpHandler(toolkitTools, cfg = {}) {
|
|
|
196
196
|
/** Validates protocol header + session (stateful). Writes the HTTP error itself on failure. */
|
|
197
197
|
async function checkSession(ctx, req, res) {
|
|
198
198
|
const v = header(req, "MCP-Protocol-Version");
|
|
199
|
-
if (v !== "" && !
|
|
199
|
+
if (v !== "" && !versions.includes(v)) {
|
|
200
200
|
httpError(res, 400, "Bad Request: unsupported MCP-Protocol-Version");
|
|
201
201
|
return false;
|
|
202
202
|
}
|
|
@@ -655,9 +655,7 @@ export function createMcpHandler(toolkitTools, cfg = {}) {
|
|
|
655
655
|
if (method === "initialize")
|
|
656
656
|
return false;
|
|
657
657
|
const hv = header(req, "MCP-Protocol-Version");
|
|
658
|
-
if (hv
|
|
659
|
-
return true;
|
|
660
|
-
if (hv !== "" && versions.includes(hv))
|
|
658
|
+
if (hv !== "" && legacy.includes(hv))
|
|
661
659
|
return false;
|
|
662
660
|
return method === "server/discover" || header(req, "Mcp-Method") !== "" || (meta !== undefined && META_VERSION in meta);
|
|
663
661
|
}
|
package/dist/mcpserve.js
CHANGED
|
@@ -15,7 +15,6 @@
|
|
|
15
15
|
import http from "node:http";
|
|
16
16
|
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
17
17
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
18
|
-
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
19
18
|
import { ListToolsRequestSchema, CallToolRequestSchema, McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
|
|
20
19
|
const DEFAULT_NAME = "toolnexus";
|
|
21
20
|
const DEFAULT_VERSION = "0.1.0";
|
|
@@ -133,6 +132,8 @@ function partBlocks(parts) {
|
|
|
133
132
|
*/
|
|
134
133
|
export async function serveLocalMcp(tools, cfg) {
|
|
135
134
|
const exposed = exposedMcpTools(tools, cfg);
|
|
135
|
+
// Loaded on first serve, not at import: the transport pulls in @hono/node-server.
|
|
136
|
+
const { StreamableHTTPServerTransport } = await import("@modelcontextprotocol/sdk/server/streamableHttp.js");
|
|
136
137
|
const token = randomBytes(32).toString("hex");
|
|
137
138
|
const want = Buffer.from("Bearer " + token);
|
|
138
139
|
const server = http.createServer((req, res) => {
|