toolnexus 0.24.0 → 0.26.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 +151 -1
- package/dist/acp.d.ts +37 -6
- package/dist/acp.js +239 -52
- package/dist/classifier.d.ts +189 -31
- package/dist/classifier.js +589 -30
- package/dist/client.d.ts +38 -8
- package/dist/client.js +102 -23
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/judge.d.ts +6 -3
- package/dist/judge.js +13 -1
- package/dist/mcp.d.ts +32 -7
- package/dist/mcp.js +125 -36
- package/dist/mcp2026/auth.d.ts +137 -0
- package/dist/mcp2026/auth.js +446 -0
- package/dist/mcp2026/client.d.ts +48 -0
- package/dist/mcp2026/client.js +160 -0
- package/dist/mcp2026/protocol.d.ts +54 -0
- package/dist/mcp2026/protocol.js +113 -0
- package/dist/mcp2026/skills.d.ts +5 -0
- package/dist/mcp2026/skills.js +25 -0
- package/dist/mcp2026/source.d.ts +86 -0
- package/dist/mcp2026/source.js +372 -0
- package/dist/mcp2026/stdio.d.ts +24 -0
- package/dist/mcp2026/stdio.js +99 -0
- package/dist/mcp2026/toolsearch.d.ts +5 -0
- package/dist/mcp2026/toolsearch.js +58 -0
- package/dist/mcphandler.d.ts +169 -0
- package/dist/mcphandler.js +1145 -0
- package/dist/mcpheader.d.ts +5 -0
- package/dist/mcpheader.js +18 -0
- package/dist/mcpserve.d.ts +24 -15
- package/dist/mcpserve.js +105 -2
- package/dist/mcpserverauth.d.ts +48 -0
- package/dist/mcpserverauth.js +203 -0
- package/dist/serve.js +7 -0
- package/dist/skill.d.ts +21 -0
- package/dist/skill.js +44 -0
- package/dist/toolkit.d.ts +20 -1
- package/dist/toolkit.js +25 -3
- package/dist/types.d.ts +22 -0
- package/package.json +4 -3
- package/src/acp.ts +258 -55
- package/src/classifier.ts +677 -46
- package/src/client.ts +130 -27
- package/src/index.ts +2 -0
- package/src/judge.ts +19 -4
- package/src/mcp.ts +162 -43
- package/src/mcp2026/auth.ts +473 -0
- package/src/mcp2026/client.ts +161 -0
- package/src/mcp2026/protocol.ts +135 -0
- package/src/mcp2026/skills.ts +22 -0
- package/src/mcp2026/source.ts +407 -0
- package/src/mcp2026/stdio.ts +102 -0
- package/src/mcp2026/toolsearch.ts +56 -0
- package/src/mcphandler.ts +1252 -0
- package/src/mcpheader.ts +19 -0
- package/src/mcpserve.ts +115 -2
- package/src/mcpserverauth.ts +216 -0
- package/src/serve.ts +7 -0
- package/src/skill.ts +50 -0
- package/src/toolkit.ts +31 -4
- package/src/types.ts +23 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export declare const PROTOCOL_VERSION = "2026-07-28";
|
|
2
|
+
export declare const META_VERSION = "io.modelcontextprotocol/protocolVersion";
|
|
3
|
+
export declare const META_CLIENT_CAPS = "io.modelcontextprotocol/clientCapabilities";
|
|
4
|
+
export declare const META_CLIENT_INFO = "io.modelcontextprotocol/clientInfo";
|
|
5
|
+
export declare const META_SERVER_INFO = "io.modelcontextprotocol/serverInfo";
|
|
6
|
+
export declare const SKILLS_EXT = "io.modelcontextprotocol/skills";
|
|
7
|
+
export declare const CODE_METHOD_NOT_FOUND = -32601;
|
|
8
|
+
export declare const CODE_INVALID_PARAMS = -32602;
|
|
9
|
+
export declare const CODE_HEADER_MISMATCH = -32020;
|
|
10
|
+
export declare const CODE_UNSUPPORTED_PROTOCOL = -32022;
|
|
11
|
+
/** A JSON object. */
|
|
12
|
+
export type Obj = Record<string, any>;
|
|
13
|
+
/** A JSON-RPC error returned by the server. `httpStatus` is set when it came over HTTP. */
|
|
14
|
+
export declare class RpcError extends Error {
|
|
15
|
+
code: number;
|
|
16
|
+
data?: unknown;
|
|
17
|
+
httpStatus?: number;
|
|
18
|
+
constructor(code: number, message: string, data?: unknown);
|
|
19
|
+
}
|
|
20
|
+
/** A non-2xx HTTP reply that carried no JSON-RPC message. */
|
|
21
|
+
export declare class HttpStatusError extends Error {
|
|
22
|
+
status: number;
|
|
23
|
+
constructor(status: number, message?: string);
|
|
24
|
+
}
|
|
25
|
+
/** A 2xx reply that is not a JSON-RPC response for the request (§2.2a: falls back on discover). */
|
|
26
|
+
export declare class NotJsonRpcError extends Error {
|
|
27
|
+
}
|
|
28
|
+
export declare const DEFAULT_TIMEOUT_MS = 30000;
|
|
29
|
+
export interface HttpRequest {
|
|
30
|
+
method: string;
|
|
31
|
+
url: string;
|
|
32
|
+
headers?: Record<string, string>;
|
|
33
|
+
body?: string;
|
|
34
|
+
timeoutMs?: number;
|
|
35
|
+
signal?: AbortSignal;
|
|
36
|
+
}
|
|
37
|
+
export interface HttpResponse {
|
|
38
|
+
status: number;
|
|
39
|
+
headers: Record<string, string>;
|
|
40
|
+
body: string;
|
|
41
|
+
}
|
|
42
|
+
/** The seam for every outbound HTTP call. */
|
|
43
|
+
export type HttpFn = (r: HttpRequest) => Promise<HttpResponse>;
|
|
44
|
+
export declare function trustDevCert(pem: string): void;
|
|
45
|
+
/** node:http(s) request; reads the whole body (capped at 16 MiB). Never follows redirects. */
|
|
46
|
+
export declare const defaultHttp: HttpFn;
|
|
47
|
+
export declare function getJSON(httpFn: HttpFn, url: string): Promise<Obj>;
|
|
48
|
+
/** A small async mutex (one login / one refresh at a time). */
|
|
49
|
+
export declare class Mutex {
|
|
50
|
+
private tail;
|
|
51
|
+
lock<T>(f: () => Promise<T>): Promise<T>;
|
|
52
|
+
}
|
|
53
|
+
/** Plain value if printable ASCII without edge whitespace, else the =?base64?…?= sentinel (§2.3). */
|
|
54
|
+
export declare function encodeHeader(v: string): string;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP 2026-07-28 wire constants + a tiny node:http(s) layer (SPEC §2.1, §2.3). No MCP SDK.
|
|
3
|
+
* node:http instead of fetch so a per-request trust store is possible (dev authorization servers).
|
|
4
|
+
*/
|
|
5
|
+
import http from "node:http";
|
|
6
|
+
import https from "node:https";
|
|
7
|
+
import tls from "node:tls";
|
|
8
|
+
export const PROTOCOL_VERSION = "2026-07-28";
|
|
9
|
+
export const META_VERSION = "io.modelcontextprotocol/protocolVersion";
|
|
10
|
+
export const META_CLIENT_CAPS = "io.modelcontextprotocol/clientCapabilities";
|
|
11
|
+
export const META_CLIENT_INFO = "io.modelcontextprotocol/clientInfo";
|
|
12
|
+
export const META_SERVER_INFO = "io.modelcontextprotocol/serverInfo";
|
|
13
|
+
export const SKILLS_EXT = "io.modelcontextprotocol/skills";
|
|
14
|
+
export const CODE_METHOD_NOT_FOUND = -32601;
|
|
15
|
+
export const CODE_INVALID_PARAMS = -32602;
|
|
16
|
+
export const CODE_HEADER_MISMATCH = -32020;
|
|
17
|
+
export const CODE_UNSUPPORTED_PROTOCOL = -32022;
|
|
18
|
+
/** A JSON-RPC error returned by the server. `httpStatus` is set when it came over HTTP. */
|
|
19
|
+
export class RpcError extends Error {
|
|
20
|
+
code;
|
|
21
|
+
data;
|
|
22
|
+
httpStatus;
|
|
23
|
+
constructor(code, message, data) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.code = code;
|
|
26
|
+
if (data !== undefined)
|
|
27
|
+
this.data = data;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** A non-2xx HTTP reply that carried no JSON-RPC message. */
|
|
31
|
+
export class HttpStatusError extends Error {
|
|
32
|
+
status;
|
|
33
|
+
constructor(status, message) {
|
|
34
|
+
super(message ?? `HTTP ${status}`);
|
|
35
|
+
this.status = status;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** A 2xx reply that is not a JSON-RPC response for the request (§2.2a: falls back on discover). */
|
|
39
|
+
export class NotJsonRpcError extends Error {
|
|
40
|
+
}
|
|
41
|
+
export const DEFAULT_TIMEOUT_MS = 30_000;
|
|
42
|
+
const MAX_RESPONSE_BYTES = 16 << 20;
|
|
43
|
+
// Test/dev only: extra CA certificates trusted by defaultHttp (a dev authorization server's
|
|
44
|
+
// self-signed certificate). Not exported from the package index.
|
|
45
|
+
const extraCAs = [];
|
|
46
|
+
export function trustDevCert(pem) {
|
|
47
|
+
extraCAs.push(pem);
|
|
48
|
+
}
|
|
49
|
+
/** node:http(s) request; reads the whole body (capped at 16 MiB). Never follows redirects. */
|
|
50
|
+
export const defaultHttp = (r) => new Promise((resolve, reject) => {
|
|
51
|
+
const u = new URL(r.url);
|
|
52
|
+
const opts = { method: r.method, headers: r.headers ?? {}, agent: false };
|
|
53
|
+
if (u.protocol === "https:" && extraCAs.length)
|
|
54
|
+
opts.ca = [...tls.rootCertificates, ...extraCAs];
|
|
55
|
+
if (r.signal?.aborted)
|
|
56
|
+
return reject(r.signal.reason ?? new Error("aborted"));
|
|
57
|
+
const req = (u.protocol === "https:" ? https : http).request(u, opts, (res) => {
|
|
58
|
+
const chunks = [];
|
|
59
|
+
let n = 0;
|
|
60
|
+
res.on("data", (c) => {
|
|
61
|
+
n += c.length;
|
|
62
|
+
if (n > MAX_RESPONSE_BYTES) {
|
|
63
|
+
req.destroy(new Error("response too large"));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
chunks.push(c);
|
|
67
|
+
});
|
|
68
|
+
res.on("end", () => {
|
|
69
|
+
const headers = {};
|
|
70
|
+
for (const [k, v] of Object.entries(res.headers))
|
|
71
|
+
if (v !== undefined)
|
|
72
|
+
headers[k] = Array.isArray(v) ? v.join(", ") : v;
|
|
73
|
+
resolve({ status: res.statusCode ?? 0, headers, body: Buffer.concat(chunks).toString("utf8") });
|
|
74
|
+
});
|
|
75
|
+
res.on("error", reject);
|
|
76
|
+
});
|
|
77
|
+
const onAbort = () => req.destroy(r.signal?.reason instanceof Error ? r.signal.reason : new Error("aborted"));
|
|
78
|
+
r.signal?.addEventListener("abort", onAbort, { once: true });
|
|
79
|
+
req.on("close", () => r.signal?.removeEventListener("abort", onAbort));
|
|
80
|
+
req.setTimeout(r.timeoutMs ?? DEFAULT_TIMEOUT_MS, () => req.destroy(new Error(`request to ${u.host} timed out`)));
|
|
81
|
+
req.on("error", reject);
|
|
82
|
+
req.end(r.body);
|
|
83
|
+
});
|
|
84
|
+
export async function getJSON(httpFn, url) {
|
|
85
|
+
const res = await httpFn({ method: "GET", url, headers: { Accept: "application/json" } });
|
|
86
|
+
if (res.status !== 200)
|
|
87
|
+
throw new Error(`GET ${url}: HTTP ${res.status}`);
|
|
88
|
+
const v = JSON.parse(res.body);
|
|
89
|
+
if (!v || typeof v !== "object" || Array.isArray(v))
|
|
90
|
+
throw new Error(`GET ${url}: not a JSON object`);
|
|
91
|
+
return v;
|
|
92
|
+
}
|
|
93
|
+
/** A small async mutex (one login / one refresh at a time). */
|
|
94
|
+
export class Mutex {
|
|
95
|
+
tail = Promise.resolve();
|
|
96
|
+
async lock(f) {
|
|
97
|
+
let release;
|
|
98
|
+
const prev = this.tail;
|
|
99
|
+
this.tail = new Promise((r) => (release = r));
|
|
100
|
+
await prev;
|
|
101
|
+
try {
|
|
102
|
+
return await f();
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
release();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/** Plain value if printable ASCII without edge whitespace, else the =?base64?…?= sentinel (§2.3). */
|
|
110
|
+
export function encodeHeader(v) {
|
|
111
|
+
const safe = v === v.trim() && !(v.startsWith("=?base64?") && v.endsWith("?=")) && /^[\x20-\x7e]*$/.test(v);
|
|
112
|
+
return safe ? v : "=?base64?" + Buffer.from(v, "utf8").toString("base64") + "?=";
|
|
113
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Skill URI safety (python-sdk #3685): a skill:// URI is percent-decoded ONCE, then rejected if it
|
|
2
|
+
// still contains '%' (double encoding), a backslash, NUL, an absolute/empty path, or a "."/".."
|
|
3
|
+
// segment. Both the server (before lookup) and the client (before writing files) use it.
|
|
4
|
+
/** Splits skill://<name>/<path> into a validated name and slash-separated relative path. Throws on anything unsafe. */
|
|
5
|
+
export function parseSkillURI(uri) {
|
|
6
|
+
if (typeof uri !== "string" || !uri.startsWith("skill://"))
|
|
7
|
+
throw new Error("not a skill:// URI");
|
|
8
|
+
let dec;
|
|
9
|
+
try {
|
|
10
|
+
dec = decodeURIComponent(uri.slice("skill://".length));
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
throw new Error("bad percent-encoding");
|
|
14
|
+
}
|
|
15
|
+
if (/[%\\\x00]/.test(dec))
|
|
16
|
+
throw new Error("skill URI has encoded, backslash or NUL characters");
|
|
17
|
+
const segs = dec.split("/");
|
|
18
|
+
if (segs.length < 2)
|
|
19
|
+
throw new Error("skill URI needs skill://<name>/<path>");
|
|
20
|
+
for (const s of segs) {
|
|
21
|
+
if (s === "" || s === "." || s === ".." || s.includes(":"))
|
|
22
|
+
throw new Error("skill URI path escapes the skill (empty, '.', '..' or ':' segment)");
|
|
23
|
+
}
|
|
24
|
+
return { name: segs[0], rel: segs.slice(1).join("/") };
|
|
25
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { Mcp2026Client } from "./client.js";
|
|
2
|
+
import { type Obj } from "./protocol.js";
|
|
3
|
+
import { OAuth, type TokenStore } from "./auth.js";
|
|
4
|
+
import type { Answer, Request, Tool, ToolContext, ToolResult } from "../types.js";
|
|
5
|
+
import type { SkillDef } from "../skill.js";
|
|
6
|
+
export type WaitFor = (request: Request) => Promise<Answer>;
|
|
7
|
+
/** Per-server `auth` (§2A.1; keys camelCase as in mcp.json). */
|
|
8
|
+
export interface AuthConfig {
|
|
9
|
+
clientId?: string;
|
|
10
|
+
clientSecret?: string;
|
|
11
|
+
tokenAuthMethod?: "client_secret_basic" | "client_secret_post";
|
|
12
|
+
clientIds?: Record<string, string>;
|
|
13
|
+
clientMetadataUrl?: string;
|
|
14
|
+
redirectUri?: string;
|
|
15
|
+
mode?: "auto" | "pause";
|
|
16
|
+
omitRefreshResource?: boolean;
|
|
17
|
+
allowHttp?: boolean;
|
|
18
|
+
}
|
|
19
|
+
export interface Load2026Options {
|
|
20
|
+
waitFor?: WaitFor;
|
|
21
|
+
tokenStore?: TokenStore;
|
|
22
|
+
openUrl?: (url: string) => unknown | Promise<unknown>;
|
|
23
|
+
signal?: AbortSignal;
|
|
24
|
+
/** Final exposed names to drop (Toolkit `disableTools`); applied before tool search too. */
|
|
25
|
+
disableTools?: string[];
|
|
26
|
+
}
|
|
27
|
+
/** The OAuth engine for one remote server (§2A.1). */
|
|
28
|
+
export declare function newOAuth(auth: AuthConfig | undefined, o: Load2026Options, store: TokenStore): OAuth;
|
|
29
|
+
export declare function defaultTokenStore(o: Load2026Options): TokenStore;
|
|
30
|
+
/** What the probe decided. `fallback` ⇒ take the frozen SDK path; anything thrown ⇒ status failed. */
|
|
31
|
+
export type Probe = {
|
|
32
|
+
kind: "modern";
|
|
33
|
+
server: ModernServer;
|
|
34
|
+
} | {
|
|
35
|
+
kind: "fallback";
|
|
36
|
+
};
|
|
37
|
+
export interface ProbeTarget {
|
|
38
|
+
name: string;
|
|
39
|
+
timeout: number;
|
|
40
|
+
tools?: Record<string, boolean>;
|
|
41
|
+
toolSearch?: boolean;
|
|
42
|
+
description?: string;
|
|
43
|
+
remote?: {
|
|
44
|
+
url: string;
|
|
45
|
+
headers?: Record<string, string>;
|
|
46
|
+
oauth: OAuth;
|
|
47
|
+
};
|
|
48
|
+
local?: {
|
|
49
|
+
argv: string[];
|
|
50
|
+
env: NodeJS.ProcessEnv;
|
|
51
|
+
cwd?: string;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/** Discover first (§2.2). Resolves `modern` (tools listed) or `fallback`; throws on a load failure. */
|
|
55
|
+
export declare function probe(t: ProbeTarget, o: Load2026Options): Promise<Probe>;
|
|
56
|
+
export declare class ModernServer {
|
|
57
|
+
private t;
|
|
58
|
+
private o;
|
|
59
|
+
readonly client: Mcp2026Client;
|
|
60
|
+
readonly discover: Obj;
|
|
61
|
+
defs: Obj[];
|
|
62
|
+
skills: SkillDef[];
|
|
63
|
+
private all;
|
|
64
|
+
private cacheUntil;
|
|
65
|
+
private pend;
|
|
66
|
+
constructor(t: ProbeTarget, client: Mcp2026Client, disc: Obj, o: Load2026Options);
|
|
67
|
+
private get oauth();
|
|
68
|
+
private req;
|
|
69
|
+
load(): Promise<void>;
|
|
70
|
+
private refreshTools;
|
|
71
|
+
private loadSkills;
|
|
72
|
+
/** The toolkit tools for this server: every (filtered) server tool, or the two tool-search tools. */
|
|
73
|
+
tools(): Tool[];
|
|
74
|
+
private convert;
|
|
75
|
+
/** tools/call with MRTR rounds (§2.5) and OAuth pauses (§2A.1 10); never re-sends after a failure (§2.6). */
|
|
76
|
+
run(name: string, args: Obj, ctx?: ToolContext): Promise<ToolResult>;
|
|
77
|
+
private suspend;
|
|
78
|
+
private drive;
|
|
79
|
+
private toRequest;
|
|
80
|
+
/** resources/list for list_mcp_resources (§4C). */
|
|
81
|
+
listResources(signal?: AbortSignal): Promise<Obj[]>;
|
|
82
|
+
readResource(uri: string, signal?: AbortSignal): Promise<Obj[]>;
|
|
83
|
+
/** Raw (unfiltered) tool defs, for listMcpTools. */
|
|
84
|
+
rawDefs(): Obj[];
|
|
85
|
+
close(): Promise<void>;
|
|
86
|
+
}
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The 2026-07-28 direct path behind loadMcp (SPEC §2.2–§2.8, §2A): discover first, then tools,
|
|
3
|
+
* skills and tool search; MRTR and OAuth pauses surface through §10 (waitFor inline, or a
|
|
4
|
+
* `{status:"pending"}` suspension resumed via `ctx.answer`). `probe` reports `fallback` when the
|
|
5
|
+
* server is pre-2026 so loadMcp takes the frozen SDK path (§2.F).
|
|
6
|
+
*/
|
|
7
|
+
import { randomBytes } from "node:crypto";
|
|
8
|
+
import { Mcp2026Client } from "./client.js";
|
|
9
|
+
import { HttpStatusError, NotJsonRpcError, PROTOCOL_VERSION, RpcError, SKILLS_EXT } from "./protocol.js";
|
|
10
|
+
import { LoopbackAuthorizer, MemoryTokenStore, OAuth, PauseAuthorizer, PendingError } from "./auth.js";
|
|
11
|
+
import { spawnStdio, StdioClosedError } from "./stdio.js";
|
|
12
|
+
import { rank } from "./toolsearch.js";
|
|
13
|
+
import { sanitize } from "../types.js";
|
|
14
|
+
import { answerToElicitResult, elicitationToRequest, mapCallToolResult, mcpHints } from "../mcp.js";
|
|
15
|
+
/** The OAuth engine for one remote server (§2A.1). */
|
|
16
|
+
export function newOAuth(auth, o, store) {
|
|
17
|
+
const a = auth ?? {};
|
|
18
|
+
const mode = a.mode ?? "auto";
|
|
19
|
+
let authorizer;
|
|
20
|
+
if (mode === "pause") {
|
|
21
|
+
if (!a.redirectUri)
|
|
22
|
+
throw new Error("auth.redirectUri is required for pause mode");
|
|
23
|
+
authorizer = new PauseAuthorizer({ redirect: a.redirectUri });
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
authorizer = new LoopbackAuthorizer(o.openUrl ? { open: o.openUrl } : {});
|
|
27
|
+
}
|
|
28
|
+
return new OAuth({
|
|
29
|
+
authorizer, store,
|
|
30
|
+
clientId: a.clientId, clientSecret: a.clientSecret, tokenAuthMethod: a.tokenAuthMethod,
|
|
31
|
+
clientIds: a.clientIds, clientMetadataUrl: a.clientMetadataUrl,
|
|
32
|
+
omitRefreshResource: a.omitRefreshResource, allowHttp: a.allowHttp,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
export function defaultTokenStore(o) {
|
|
36
|
+
return o.tokenStore ?? new MemoryTokenStore();
|
|
37
|
+
}
|
|
38
|
+
function deniedError(a) {
|
|
39
|
+
return new Error(`authorization denied: ${a?.reason ?? "cancelled"}`);
|
|
40
|
+
}
|
|
41
|
+
function callbackUrlOf(a) {
|
|
42
|
+
const u = a.data?.callbackUrl;
|
|
43
|
+
if (typeof u !== "string" || !u)
|
|
44
|
+
throw new Error("authorization answer carries no data.callbackUrl");
|
|
45
|
+
return u;
|
|
46
|
+
}
|
|
47
|
+
/** Runs f; an OAuth pause is answered inline through waitFor, else it is a load failure. */
|
|
48
|
+
async function authed(oauth, waitFor, f) {
|
|
49
|
+
try {
|
|
50
|
+
return await f();
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
if (!(e instanceof PendingError) || !oauth)
|
|
54
|
+
throw e;
|
|
55
|
+
if (!waitFor)
|
|
56
|
+
throw new Error("authorization pending");
|
|
57
|
+
const a = await waitFor({ ...e.pending });
|
|
58
|
+
if (!a?.ok)
|
|
59
|
+
throw deniedError(a);
|
|
60
|
+
// resume() stores the token (and re-sends the paused read); f() then runs again, authorized.
|
|
61
|
+
await oauth.resume(e.pending.id, callbackUrlOf(a));
|
|
62
|
+
return f();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** §2.2a: which discover failures mean "old server". */
|
|
66
|
+
function isFallbackError(e) {
|
|
67
|
+
if (e instanceof RpcError)
|
|
68
|
+
return true;
|
|
69
|
+
if (e instanceof HttpStatusError)
|
|
70
|
+
return e.status === 400 || e.status === 404 || e.status === 405 || e.status >= 500;
|
|
71
|
+
if (e instanceof NotJsonRpcError)
|
|
72
|
+
return true;
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
function supports2026(r) {
|
|
76
|
+
return Array.isArray(r.supportedVersions) && r.supportedVersions.includes(PROTOCOL_VERSION);
|
|
77
|
+
}
|
|
78
|
+
/** Discover first (§2.2). Resolves `modern` (tools listed) or `fallback`; throws on a load failure. */
|
|
79
|
+
export async function probe(t, o) {
|
|
80
|
+
let client;
|
|
81
|
+
if (t.remote) {
|
|
82
|
+
client = new Mcp2026Client(t.remote.url, { headers: t.remote.headers, auth: t.remote.oauth, timeoutMs: t.timeout });
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
const tr = await spawnStdio(t.local.argv, t.local.env, t.local.cwd);
|
|
86
|
+
client = new Mcp2026Client("", { transport: tr, timeoutMs: t.timeout });
|
|
87
|
+
}
|
|
88
|
+
const oauth = t.remote?.oauth;
|
|
89
|
+
let disc;
|
|
90
|
+
try {
|
|
91
|
+
// stdio: an old server may ignore a pre-initialize request ⇒ min(timeout, 5 s) then fall back.
|
|
92
|
+
const timeoutMs = t.local ? Math.min(t.timeout, 5000) : t.timeout;
|
|
93
|
+
disc = await authed(oauth, o.waitFor, () => client.call("server/discover", {}, { timeoutMs, signal: o.signal }));
|
|
94
|
+
}
|
|
95
|
+
catch (e) {
|
|
96
|
+
const stdioTimeout = !!t.local && e instanceof Error && e.message === "stdio request timed out";
|
|
97
|
+
await client.close().catch(() => { });
|
|
98
|
+
if (o.signal?.aborted)
|
|
99
|
+
throw e;
|
|
100
|
+
if (e instanceof StdioClosedError)
|
|
101
|
+
throw e;
|
|
102
|
+
if (isFallbackError(e) || stdioTimeout)
|
|
103
|
+
return { kind: "fallback" };
|
|
104
|
+
throw e;
|
|
105
|
+
}
|
|
106
|
+
if (!supports2026(disc)) {
|
|
107
|
+
await client.close().catch(() => { });
|
|
108
|
+
return { kind: "fallback" };
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
const server = new ModernServer(t, client, disc, o);
|
|
112
|
+
await server.load();
|
|
113
|
+
return { kind: "modern", server };
|
|
114
|
+
}
|
|
115
|
+
catch (e) {
|
|
116
|
+
await client.close().catch(() => { });
|
|
117
|
+
throw e;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** §2 Gap 7 filter over raw defs (same semantics as the frozen path). */
|
|
121
|
+
function filterDefs(server, defs, filter) {
|
|
122
|
+
if (!filter)
|
|
123
|
+
return defs;
|
|
124
|
+
const keys = Object.keys(filter);
|
|
125
|
+
if (keys.length === 0)
|
|
126
|
+
return defs;
|
|
127
|
+
const hasTrue = keys.some((k) => filter[k] === true);
|
|
128
|
+
const present = new Set(defs.map((d) => d.name));
|
|
129
|
+
for (const k of keys)
|
|
130
|
+
if (!present.has(k))
|
|
131
|
+
console.warn(`[toolnexus] server "${server}" tools filter name "${k}" matched no tool`);
|
|
132
|
+
return defs.filter((d) => (hasTrue ? filter[d.name] === true : filter[d.name] !== false));
|
|
133
|
+
}
|
|
134
|
+
function parseFrontmatter(md) {
|
|
135
|
+
const m = md.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
|
136
|
+
if (!m)
|
|
137
|
+
return { data: {}, body: md };
|
|
138
|
+
const data = {};
|
|
139
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
140
|
+
if (/^\s/.test(line))
|
|
141
|
+
continue;
|
|
142
|
+
const i = line.indexOf(":");
|
|
143
|
+
if (i <= 0)
|
|
144
|
+
continue;
|
|
145
|
+
let v = line.slice(i + 1).trim();
|
|
146
|
+
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'")))
|
|
147
|
+
v = v.slice(1, -1);
|
|
148
|
+
data[line.slice(0, i).trim()] = v;
|
|
149
|
+
}
|
|
150
|
+
return { data, body: m[2] };
|
|
151
|
+
}
|
|
152
|
+
const MAX_ROUNDS = 8;
|
|
153
|
+
export class ModernServer {
|
|
154
|
+
t;
|
|
155
|
+
o;
|
|
156
|
+
client;
|
|
157
|
+
discover;
|
|
158
|
+
defs = [];
|
|
159
|
+
skills = [];
|
|
160
|
+
all = [];
|
|
161
|
+
cacheUntil = Infinity;
|
|
162
|
+
pend = new Map();
|
|
163
|
+
constructor(t, client, disc, o) {
|
|
164
|
+
this.t = t;
|
|
165
|
+
this.o = o;
|
|
166
|
+
this.client = client;
|
|
167
|
+
this.discover = disc;
|
|
168
|
+
}
|
|
169
|
+
get oauth() {
|
|
170
|
+
return this.t.remote?.oauth;
|
|
171
|
+
}
|
|
172
|
+
req(f) {
|
|
173
|
+
return authed(this.oauth, this.o.waitFor, f);
|
|
174
|
+
}
|
|
175
|
+
async load() {
|
|
176
|
+
await this.refreshTools();
|
|
177
|
+
if (this.discover.capabilities?.extensions?.[SKILLS_EXT] !== undefined)
|
|
178
|
+
await this.loadSkills();
|
|
179
|
+
}
|
|
180
|
+
async refreshTools() {
|
|
181
|
+
const r = await this.req(() => this.client.listTools({ signal: this.o.signal }));
|
|
182
|
+
this.all = r.tools;
|
|
183
|
+
const off = new Set(this.o.disableTools ?? []);
|
|
184
|
+
const prefix = sanitize(this.t.name) + "_";
|
|
185
|
+
this.defs = filterDefs(this.t.name, r.tools, this.t.tools).filter((d) => !off.has(prefix + sanitize(d.name)));
|
|
186
|
+
this.cacheUntil = typeof r.ttlMs === "number" ? Date.now() + r.ttlMs : Infinity;
|
|
187
|
+
}
|
|
188
|
+
async loadSkills() {
|
|
189
|
+
const r = await this.req(() => this.client.call("skills/list", {}, { signal: this.o.signal }));
|
|
190
|
+
for (const s of Array.isArray(r.skills) ? r.skills : []) {
|
|
191
|
+
const uri = String(s?.uri ?? "");
|
|
192
|
+
try {
|
|
193
|
+
const f = await this.req(() => this.client.fetchSkill(uri, { signal: this.o.signal }));
|
|
194
|
+
const { data, body } = parseFrontmatter(f.markdown);
|
|
195
|
+
const name = data.name || f.name;
|
|
196
|
+
this.skills.push({ name, description: data.description ?? "", content: body, resources: f.resources, base: `skill://${f.name}/` });
|
|
197
|
+
}
|
|
198
|
+
catch (e) {
|
|
199
|
+
console.warn(`[toolnexus] MCP server "${this.t.name}" skill ${JSON.stringify(uri)} skipped: ${e instanceof Error ? e.message : String(e)}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/** The toolkit tools for this server: every (filtered) server tool, or the two tool-search tools. */
|
|
204
|
+
tools() {
|
|
205
|
+
const server = this.t.name;
|
|
206
|
+
if (!this.t.toolSearch)
|
|
207
|
+
return this.defs.map((d) => this.convert(d));
|
|
208
|
+
const about = this.t.description || server;
|
|
209
|
+
const search = {
|
|
210
|
+
name: sanitize(server) + "_search_tools",
|
|
211
|
+
description: `Search the tools of ${about} by keywords. Returns name, description and inputSchema of the best matches; run one with call_tool.`,
|
|
212
|
+
inputSchema: { type: "object", properties: { query: { type: "string" }, limit: { type: "integer" } }, required: ["query"] },
|
|
213
|
+
source: "mcp",
|
|
214
|
+
execute: async (args, ctx) => {
|
|
215
|
+
try {
|
|
216
|
+
if (Date.now() >= this.cacheUntil)
|
|
217
|
+
await this.refreshTools();
|
|
218
|
+
const limit = typeof args?.limit === "number" && args.limit > 0 ? Math.trunc(args.limit) : 5;
|
|
219
|
+
const hits = rank(this.defs, String(args?.query ?? ""), limit).map((d) => ({
|
|
220
|
+
name: d.name, description: d.description, inputSchema: d.inputSchema, server: this.t.description ?? "",
|
|
221
|
+
}));
|
|
222
|
+
return { output: JSON.stringify(hits), isError: false, metadata: { server } };
|
|
223
|
+
}
|
|
224
|
+
catch (e) {
|
|
225
|
+
return { output: e instanceof Error ? e.message : String(e), isError: true, metadata: { server } };
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
const call = {
|
|
230
|
+
name: sanitize(server) + "_call_tool",
|
|
231
|
+
description: "Run a tool found with search_tools.",
|
|
232
|
+
inputSchema: { type: "object", properties: { name: { type: "string" }, arguments: { type: "object" } }, required: ["name"] },
|
|
233
|
+
source: "mcp",
|
|
234
|
+
execute: async (args, ctx) => {
|
|
235
|
+
const name = String(args?.name ?? "");
|
|
236
|
+
const a = args?.arguments && typeof args.arguments === "object" && !Array.isArray(args.arguments) ? args.arguments : {};
|
|
237
|
+
if (!(ctx?.answer && this.pend.has(ctx.answer.id)) && !this.defs.some((d) => d.name === name)) {
|
|
238
|
+
return { output: `unknown tool "${name}" on ${server}`, isError: true, metadata: { server } };
|
|
239
|
+
}
|
|
240
|
+
return this.run(name, a, ctx);
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
return [search, call];
|
|
244
|
+
}
|
|
245
|
+
convert(def) {
|
|
246
|
+
const inputSchema = {
|
|
247
|
+
...def.inputSchema,
|
|
248
|
+
type: "object",
|
|
249
|
+
properties: (def.inputSchema?.properties ?? {}),
|
|
250
|
+
additionalProperties: false,
|
|
251
|
+
};
|
|
252
|
+
const hints = mcpHints(def.annotations);
|
|
253
|
+
return {
|
|
254
|
+
name: sanitize(this.t.name) + "_" + sanitize(def.name),
|
|
255
|
+
description: def.description ?? "",
|
|
256
|
+
inputSchema,
|
|
257
|
+
source: "mcp",
|
|
258
|
+
...(hints ? { hints } : {}),
|
|
259
|
+
execute: (args, ctx) => this.run(def.name, args ?? {}, ctx),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
/** tools/call with MRTR rounds (§2.5) and OAuth pauses (§2A.1 10); never re-sends after a failure (§2.6). */
|
|
263
|
+
async run(name, args, ctx) {
|
|
264
|
+
const server = this.t.name;
|
|
265
|
+
try {
|
|
266
|
+
const answer = ctx?.answer;
|
|
267
|
+
const cont = answer ? this.pend.get(answer.id) : undefined;
|
|
268
|
+
if (cont && answer) {
|
|
269
|
+
this.pend.delete(answer.id);
|
|
270
|
+
return await this.drive(cont, answer, ctx);
|
|
271
|
+
}
|
|
272
|
+
return await this.drive({ name, args, round: 0, reqs: {}, keys: [], idx: 0 }, undefined, ctx);
|
|
273
|
+
}
|
|
274
|
+
catch (e) {
|
|
275
|
+
return { output: e instanceof Error ? e.message : String(e), isError: true, metadata: { server } };
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
suspend(cont, req) {
|
|
279
|
+
this.pend.set(req.id, cont);
|
|
280
|
+
const output = req.url ? `${req.prompt}: ${req.url}` : req.prompt;
|
|
281
|
+
return { output, isError: true, metadata: { pending: req, server: this.t.name } };
|
|
282
|
+
}
|
|
283
|
+
async drive(cont, answer, ctx) {
|
|
284
|
+
const waitFor = this.o.waitFor;
|
|
285
|
+
let res;
|
|
286
|
+
if (answer) {
|
|
287
|
+
if (cont.authId) {
|
|
288
|
+
if (!answer.ok)
|
|
289
|
+
throw deniedError(answer);
|
|
290
|
+
const id = cont.authId;
|
|
291
|
+
cont.authId = undefined;
|
|
292
|
+
res = await this.oauth.resume(id, callbackUrlOf(answer));
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
cont.responses[cont.keys[cont.idx]] = answerToElicitResult(answer);
|
|
296
|
+
cont.idx++;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
for (;;) {
|
|
300
|
+
if (!res) {
|
|
301
|
+
while (cont.idx < cont.keys.length) {
|
|
302
|
+
const key = cont.keys[cont.idx];
|
|
303
|
+
const req = this.toRequest(key, cont.reqs[key]);
|
|
304
|
+
if (!waitFor)
|
|
305
|
+
return this.suspend(cont, req);
|
|
306
|
+
cont.responses[key] = answerToElicitResult(await waitFor(req));
|
|
307
|
+
cont.idx++;
|
|
308
|
+
}
|
|
309
|
+
if (cont.round >= MAX_ROUNDS)
|
|
310
|
+
throw new Error(`tools/call ${cont.name}: too many input_required rounds`);
|
|
311
|
+
const p = { name: cont.name, arguments: cont.args };
|
|
312
|
+
if (cont.responses)
|
|
313
|
+
p.inputResponses = cont.responses;
|
|
314
|
+
if (cont.state !== undefined && cont.state !== null)
|
|
315
|
+
p.requestState = cont.state;
|
|
316
|
+
try {
|
|
317
|
+
res = await this.client.call("tools/call", p, { timeoutMs: ctx?.timeout, signal: ctx?.signal });
|
|
318
|
+
}
|
|
319
|
+
catch (e) {
|
|
320
|
+
if (!(e instanceof PendingError) || !this.oauth)
|
|
321
|
+
throw e;
|
|
322
|
+
// Rejected before dispatch (401/403): this round has NOT run. Resume sends it once.
|
|
323
|
+
if (!waitFor) {
|
|
324
|
+
cont.authId = e.pending.id;
|
|
325
|
+
return this.suspend(cont, { ...e.pending });
|
|
326
|
+
}
|
|
327
|
+
const a = await waitFor({ ...e.pending });
|
|
328
|
+
if (!a?.ok)
|
|
329
|
+
throw deniedError(a);
|
|
330
|
+
res = await this.oauth.resume(e.pending.id, callbackUrlOf(a));
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (res.resultType !== "input_required")
|
|
334
|
+
return mapCallToolResult(res, this.t.name);
|
|
335
|
+
const reqs = res.inputRequests && typeof res.inputRequests === "object" && !Array.isArray(res.inputRequests) ? res.inputRequests : {};
|
|
336
|
+
for (const [k, v] of Object.entries(reqs)) {
|
|
337
|
+
if (!v || typeof v !== "object" || Array.isArray(v))
|
|
338
|
+
throw new Error(`tools/call ${cont.name}: malformed input request "${k}"`);
|
|
339
|
+
}
|
|
340
|
+
cont.round++;
|
|
341
|
+
cont.state = res.requestState;
|
|
342
|
+
cont.responses = {};
|
|
343
|
+
cont.reqs = reqs;
|
|
344
|
+
cont.keys = Object.keys(reqs);
|
|
345
|
+
cont.idx = 0;
|
|
346
|
+
res = undefined;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
toRequest(key, ir) {
|
|
350
|
+
if (ir.method !== "elicitation/create")
|
|
351
|
+
throw new Error(`unsupported input request method ${JSON.stringify(ir.method)} ("${key}")`);
|
|
352
|
+
const req = elicitationToRequest(ir.params ?? {});
|
|
353
|
+
req.id = "mrtr-" + randomBytes(9).toString("base64url");
|
|
354
|
+
return req;
|
|
355
|
+
}
|
|
356
|
+
/** resources/list for list_mcp_resources (§4C). */
|
|
357
|
+
async listResources(signal) {
|
|
358
|
+
const r = await this.req(() => this.client.call("resources/list", {}, { signal }));
|
|
359
|
+
return Array.isArray(r.resources) ? r.resources : [];
|
|
360
|
+
}
|
|
361
|
+
async readResource(uri, signal) {
|
|
362
|
+
const r = await this.req(() => this.client.call("resources/read", { uri }, { signal }));
|
|
363
|
+
return Array.isArray(r.contents) ? r.contents : [];
|
|
364
|
+
}
|
|
365
|
+
/** Raw (unfiltered) tool defs, for listMcpTools. */
|
|
366
|
+
rawDefs() {
|
|
367
|
+
return this.all;
|
|
368
|
+
}
|
|
369
|
+
close() {
|
|
370
|
+
return this.client.close();
|
|
371
|
+
}
|
|
372
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stdio transport for the 2026 path (§2.3): one JSON-RPC message per line, responses demultiplexed
|
|
3
|
+
* by id (concurrent calls allowed), stderr passed through and never parsed, a dead child fails every
|
|
4
|
+
* pending call instead of hanging. No headers, no auth.
|
|
5
|
+
*/
|
|
6
|
+
import { type ChildProcess } from "node:child_process";
|
|
7
|
+
import type { Transport } from "./client.js";
|
|
8
|
+
import type { Obj } from "./protocol.js";
|
|
9
|
+
/** The child exited / closed stdout before answering. */
|
|
10
|
+
export declare class StdioClosedError extends Error {
|
|
11
|
+
}
|
|
12
|
+
export declare class StdioTransport implements Transport {
|
|
13
|
+
private pending;
|
|
14
|
+
private err?;
|
|
15
|
+
private done;
|
|
16
|
+
private child;
|
|
17
|
+
constructor(child: ChildProcess);
|
|
18
|
+
/** Writes one line and waits for the response with the same id. Never retried. */
|
|
19
|
+
roundTrip(id: number, body: string, timeoutMs: number, signal?: AbortSignal): Promise<Obj>;
|
|
20
|
+
/** Close stdin, then SIGKILL after 2 s if the child is still alive. */
|
|
21
|
+
close(): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
/** Spawns argv (no shell) and returns its transport; rejects if the spawn itself fails. */
|
|
24
|
+
export declare function spawnStdio(argv: string[], env: NodeJS.ProcessEnv, cwd?: string): Promise<StdioTransport>;
|