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.
Files changed (63) hide show
  1. package/README.md +151 -1
  2. package/dist/acp.d.ts +37 -6
  3. package/dist/acp.js +239 -52
  4. package/dist/classifier.d.ts +189 -31
  5. package/dist/classifier.js +589 -30
  6. package/dist/client.d.ts +38 -8
  7. package/dist/client.js +102 -23
  8. package/dist/index.d.ts +2 -0
  9. package/dist/index.js +2 -0
  10. package/dist/judge.d.ts +6 -3
  11. package/dist/judge.js +13 -1
  12. package/dist/mcp.d.ts +32 -7
  13. package/dist/mcp.js +125 -36
  14. package/dist/mcp2026/auth.d.ts +137 -0
  15. package/dist/mcp2026/auth.js +446 -0
  16. package/dist/mcp2026/client.d.ts +48 -0
  17. package/dist/mcp2026/client.js +160 -0
  18. package/dist/mcp2026/protocol.d.ts +54 -0
  19. package/dist/mcp2026/protocol.js +113 -0
  20. package/dist/mcp2026/skills.d.ts +5 -0
  21. package/dist/mcp2026/skills.js +25 -0
  22. package/dist/mcp2026/source.d.ts +86 -0
  23. package/dist/mcp2026/source.js +372 -0
  24. package/dist/mcp2026/stdio.d.ts +24 -0
  25. package/dist/mcp2026/stdio.js +99 -0
  26. package/dist/mcp2026/toolsearch.d.ts +5 -0
  27. package/dist/mcp2026/toolsearch.js +58 -0
  28. package/dist/mcphandler.d.ts +169 -0
  29. package/dist/mcphandler.js +1145 -0
  30. package/dist/mcpheader.d.ts +5 -0
  31. package/dist/mcpheader.js +18 -0
  32. package/dist/mcpserve.d.ts +24 -15
  33. package/dist/mcpserve.js +105 -2
  34. package/dist/mcpserverauth.d.ts +48 -0
  35. package/dist/mcpserverauth.js +203 -0
  36. package/dist/serve.js +7 -0
  37. package/dist/skill.d.ts +21 -0
  38. package/dist/skill.js +44 -0
  39. package/dist/toolkit.d.ts +20 -1
  40. package/dist/toolkit.js +25 -3
  41. package/dist/types.d.ts +22 -0
  42. package/package.json +4 -3
  43. package/src/acp.ts +258 -55
  44. package/src/classifier.ts +677 -46
  45. package/src/client.ts +130 -27
  46. package/src/index.ts +2 -0
  47. package/src/judge.ts +19 -4
  48. package/src/mcp.ts +162 -43
  49. package/src/mcp2026/auth.ts +473 -0
  50. package/src/mcp2026/client.ts +161 -0
  51. package/src/mcp2026/protocol.ts +135 -0
  52. package/src/mcp2026/skills.ts +22 -0
  53. package/src/mcp2026/source.ts +407 -0
  54. package/src/mcp2026/stdio.ts +102 -0
  55. package/src/mcp2026/toolsearch.ts +56 -0
  56. package/src/mcphandler.ts +1252 -0
  57. package/src/mcpheader.ts +19 -0
  58. package/src/mcpserve.ts +115 -2
  59. package/src/mcpserverauth.ts +216 -0
  60. package/src/serve.ts +7 -0
  61. package/src/skill.ts +50 -0
  62. package/src/toolkit.ts +31 -4
  63. package/src/types.ts +23 -0
@@ -0,0 +1,446 @@
1
+ // Client-side OAuth for MCP 2026-07-28 (basic/authorization). Node stdlib only.
2
+ // Flow: 401/403 -> WWW-Authenticate -> PRM (RFC 9728) -> AS metadata (RFC 8414 / OIDC)
3
+ // -> client id (pre-registered | CIMD) -> code + PKCE S256 + resource (RFC 8707)
4
+ // -> iss (RFC 9207) + state check -> token -> retry with Bearer.
5
+ import { spawn } from "node:child_process";
6
+ import { createHash, randomBytes } from "node:crypto";
7
+ import http from "node:http";
8
+ import { defaultHttp, getJSON, Mutex } from "./protocol.js";
9
+ function closeServer(hs) {
10
+ return new Promise((r) => {
11
+ hs.close(() => r());
12
+ hs.closeAllConnections();
13
+ });
14
+ }
15
+ const inspect = Symbol.for("nodejs.util.inspect.custom");
16
+ /** Bound to the issuer that minted it and the resource it is for. Never log it (it prints redacted). */
17
+ export class Token {
18
+ accessToken = "";
19
+ refreshToken = "";
20
+ /** ms since epoch; 0 = no expiry. */
21
+ expiresAt = 0;
22
+ scopes = [];
23
+ issuer = "";
24
+ clientId = "";
25
+ /** The protected resource (server URI) the token is bound to. */
26
+ resource = "";
27
+ constructor(init = {}) {
28
+ Object.assign(this, init);
29
+ }
30
+ toString() {
31
+ return `Token{issuer=${this.issuer}, redacted}`;
32
+ }
33
+ toJSON() {
34
+ return this.toString();
35
+ }
36
+ [inspect]() {
37
+ return this.toString();
38
+ }
39
+ }
40
+ export class MemoryTokenStore {
41
+ m = new Map();
42
+ load(r) { return this.m.get(r); }
43
+ save(r, t) { this.m.set(r, t); }
44
+ delete(r) { this.m.delete(r); }
45
+ }
46
+ /** Thrown instead of blocking when the authorizer pauses. Call oauth.resume(id, callbackURL). */
47
+ export class PendingError extends Error {
48
+ pending;
49
+ constructor(p) {
50
+ super("authorization pending: " + p.prompt);
51
+ this.pending = p;
52
+ }
53
+ }
54
+ /** The Pending inside err, or undefined. */
55
+ export function pendingOf(err) {
56
+ return err instanceof PendingError ? err.pending : undefined;
57
+ }
58
+ /** What a pausing AuthSession.wait throws. */
59
+ export const PAUSE = new Error("authorization paused");
60
+ /** Never blocks: every authorization becomes a Pending. */
61
+ export class PauseAuthorizer {
62
+ redirect;
63
+ constructor(o) {
64
+ this.redirect = o.redirect;
65
+ }
66
+ async begin() {
67
+ return { redirectUri: this.redirect, wait: async () => { throw PAUSE; }, close: () => { } };
68
+ }
69
+ }
70
+ /** Opens a browser and listens on 127.0.0.1:<random>/callback (RFC 8252 §7.3). */
71
+ export class LoopbackAuthorizer {
72
+ open; // default = system browser
73
+ timeoutMs = 5 * 60_000;
74
+ constructor(o = {}) {
75
+ this.open = o.open ?? openBrowser;
76
+ }
77
+ async begin() {
78
+ let deliver;
79
+ const cb = new Promise((r) => (deliver = r));
80
+ const hs = http.createServer((req, res) => {
81
+ if (new URL(req.url ?? "/", "http://x").pathname !== "/callback")
82
+ return void res.writeHead(404).end();
83
+ deliver(`http://${req.headers.host}${req.url}`);
84
+ res.end("Authorization received. You can close this window.");
85
+ });
86
+ await new Promise((r, j) => hs.once("error", j).listen(0, "127.0.0.1", () => r()));
87
+ const port = hs.address().port;
88
+ const timeoutMs = this.timeoutMs;
89
+ const open = this.open;
90
+ return {
91
+ redirectUri: `http://127.0.0.1:${port}/callback`,
92
+ close: () => closeServer(hs),
93
+ async wait(p) {
94
+ let timer;
95
+ const timeout = new Promise((_, j) => (timer = setTimeout(() => j(new Error("authorization timed out")), timeoutMs)));
96
+ // the opener may block (tests follow redirects synchronously); a failed opener must not leave
97
+ // us waiting 5 minutes for a callback that cannot come
98
+ const opened = Promise.resolve().then(() => open(p.url)).then(() => cb, (e) => { throw new Error(`open authorization URL: ${e?.message ?? e}`); });
99
+ try {
100
+ return await Promise.race([cb, opened, timeout]);
101
+ }
102
+ finally {
103
+ clearTimeout(timer);
104
+ }
105
+ },
106
+ };
107
+ }
108
+ }
109
+ function openBrowser(u) {
110
+ const [cmd, args] = process.platform === "darwin" ? ["open", [u]] : process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", u]] : ["xdg-open", [u]];
111
+ return new Promise((r, j) => spawn(cmd, args, { stdio: "ignore", detached: true }).once("error", j).once("spawn", r).unref());
112
+ }
113
+ /** The client-side auth engine; attach it as client.auth. */
114
+ export class OAuth {
115
+ authorizer;
116
+ clientId;
117
+ clientSecret;
118
+ tokenAuthMethod;
119
+ omitRefreshResource;
120
+ store;
121
+ clientIds;
122
+ clientMetadataUrl;
123
+ http;
124
+ allowHttp;
125
+ authMu = new Mutex(); // serialises refresh + interactive authorization
126
+ issuer = new Map(); // resource -> discovered issuer (only then do we send a stored token)
127
+ /** @internal paused flows by pending id (exposed for tests) */
128
+ flows = new Map();
129
+ constructor(o = {}) {
130
+ this.authorizer = o.authorizer ?? new LoopbackAuthorizer();
131
+ this.clientId = o.clientId ?? "";
132
+ this.clientSecret = o.clientSecret ?? "";
133
+ this.tokenAuthMethod = o.tokenAuthMethod ?? "";
134
+ this.omitRefreshResource = o.omitRefreshResource ?? false;
135
+ this.store = o.store ?? new MemoryTokenStore();
136
+ this.clientIds = o.clientIds ?? {};
137
+ this.clientMetadataUrl = o.clientMetadataUrl ?? "";
138
+ this.http = o.http ?? defaultHttp;
139
+ this.allowHttp = o.allowHttp ?? false;
140
+ }
141
+ /** The token to send for resource, refreshing if expired. "" = send none. */
142
+ async bearer(resource) {
143
+ const iss = this.issuer.get(resource);
144
+ let t = (await this.store.load(resource)) ?? undefined;
145
+ if (!t || !iss || t.issuer !== iss)
146
+ return ""; // only tokens from this server's AS (spec: Token Handling)
147
+ if (expired(t)) {
148
+ // one refresh for N concurrent callers (refresh tokens rotate: a 2nd refresh would fail)
149
+ return this.authMu.lock(async () => {
150
+ let cur = (await this.store.load(resource)) ?? undefined;
151
+ if (!cur)
152
+ return "";
153
+ if (expired(cur)) {
154
+ try {
155
+ await this.refresh(cur);
156
+ }
157
+ catch {
158
+ return "";
159
+ }
160
+ cur = (await this.store.load(resource)) ?? undefined;
161
+ }
162
+ return cur?.accessToken ?? "";
163
+ });
164
+ }
165
+ return t.accessToken;
166
+ }
167
+ async refresh(t) {
168
+ if (!t.refreshToken)
169
+ throw new Error("no refresh token");
170
+ const md = await discoverAS(this.http, t.issuer);
171
+ const form = new URLSearchParams({ grant_type: "refresh_token", refresh_token: t.refreshToken, client_id: t.clientId });
172
+ if (!this.omitRefreshResource)
173
+ form.set("resource", t.resource);
174
+ let nt;
175
+ try {
176
+ nt = await this.tokenRequest(String(md.token_endpoint ?? ""), form, t.issuer, t.resource, t.clientId, t.scopes);
177
+ }
178
+ catch (e) {
179
+ await this.store.delete(t.resource);
180
+ throw e;
181
+ }
182
+ if (!nt.refreshToken)
183
+ nt.refreshToken = t.refreshToken;
184
+ await this.store.save(t.resource, nt);
185
+ }
186
+ /** @internal Handles a 401/403. Resolves = retry the request now; throws PendingError = paused. */
187
+ challenge(resource, status, www, sent, retry) {
188
+ const params = parseChallenge(www);
189
+ // N concurrent 401s -> one authorization, the rest retry with its token
190
+ return this.authMu.lock(async () => {
191
+ let cur = (await this.store.load(resource)) ?? undefined;
192
+ const known = this.issuer.get(resource);
193
+ if (cur && known && cur.issuer === known && cur.accessToken !== sent && !expired(cur))
194
+ return; // someone else refreshed/authorized meanwhile
195
+ if (status === 401 && cur?.refreshToken && params.error === "invalid_token") {
196
+ try {
197
+ await this.refresh(cur);
198
+ return;
199
+ }
200
+ catch {
201
+ cur = undefined;
202
+ }
203
+ }
204
+ if (status === 403 && params.error !== "insufficient_scope")
205
+ throw new Error(`forbidden: ${params.error ?? ""}`);
206
+ const prm = await this.fetchPRM(resource, params.resource_metadata ?? "");
207
+ const servers = Array.isArray(prm.authorization_servers) ? prm.authorization_servers : [];
208
+ if (servers.length === 0)
209
+ throw new Error("PRM lists no authorization_servers");
210
+ const issuer = String(servers[0]); // RFC 9728 §7.6: client's choice; we take the first
211
+ if (!this.allowHttp && !issuer.startsWith("https://"))
212
+ throw new Error("authorization server must use https");
213
+ const md = await discoverAS(this.http, issuer);
214
+ const methods = md.code_challenge_methods_supported;
215
+ if (!Array.isArray(methods) || !methods.includes("S256"))
216
+ throw new Error("authorization server does not advertise PKCE S256; refusing");
217
+ this.issuer.set(resource, issuer);
218
+ if (status === 401 && cur && cur.issuer === issuer && !params.error)
219
+ return; // first request after restart: issuer now known
220
+ // Scope selection: challenge scope, else scopes_supported; on step-up union with previous.
221
+ let scopes = (params.scope ?? "").split(/\s+/).filter(Boolean);
222
+ if (scopes.length === 0)
223
+ scopes = strings(prm.scopes_supported);
224
+ if (cur && status === 403)
225
+ for (const s of cur.scopes)
226
+ if (!scopes.includes(s))
227
+ scopes.push(s);
228
+ let clientId = this.clientIds[issuer] || this.clientId;
229
+ const cimd = md.client_id_metadata_document_supported === true;
230
+ if (!clientId && this.clientMetadataUrl && cimd)
231
+ clientId = this.clientMetadataUrl;
232
+ if (!clientId) {
233
+ // no DCR, ever: the user registers an app once and configures its id
234
+ const why = cimd ? "auth.clientMetadataUrl is not set" : "it does not support Client ID Metadata Documents";
235
+ throw new Error(`no client identity for ${issuer}: ${why} and no client id is configured — register an app with ${issuer} and set auth.clientId (dynamic client registration is not supported)`);
236
+ }
237
+ const sess = await this.authorizer.begin();
238
+ const f = {
239
+ resource, issuer, clientId, redirect: sess.redirectUri, verifier: randStr(32), state: randStr(16),
240
+ tokenEndpoint: String(md.token_endpoint ?? ""), scopes, retry,
241
+ issSupported: md.authorization_response_iss_parameter_supported === true, expires: Date.now() + 10 * 60_000,
242
+ };
243
+ const q = new URLSearchParams({
244
+ response_type: "code", client_id: clientId, redirect_uri: f.redirect, state: f.state,
245
+ code_challenge: createHash("sha256").update(f.verifier).digest("base64url"), code_challenge_method: "S256", resource,
246
+ });
247
+ if (scopes.length)
248
+ q.set("scope", scopes.join(" "));
249
+ const p = {
250
+ id: "auth-" + randStr(12), kind: "authorization", url: String(md.authorization_endpoint ?? "") + "?" + q.toString(),
251
+ prompt: "Authorize access to " + resource, expiresAt: new Date(f.expires).toISOString().replace(/\.\d+Z$/, "Z"),
252
+ };
253
+ let cb;
254
+ try {
255
+ cb = await sess.wait(p);
256
+ }
257
+ catch (e) {
258
+ if (e === PAUSE) {
259
+ const now = Date.now();
260
+ for (const [id, old] of this.flows)
261
+ if (now > old.expires)
262
+ this.flows.delete(id); // drop abandoned pendings
263
+ this.flows.set(p.id, f);
264
+ throw new PendingError(p);
265
+ }
266
+ await sess.close();
267
+ throw e;
268
+ }
269
+ await sess.close();
270
+ await this.complete(f, cb);
271
+ });
272
+ }
273
+ /** @internal */
274
+ setRetry(id, f) {
275
+ const fl = this.flows.get(id);
276
+ if (fl)
277
+ fl.retry = f;
278
+ }
279
+ /** Finishes a paused authorization and re-runs the original operation, returning its result. */
280
+ async resume(id, callbackUrl) {
281
+ const f = this.flows.get(id);
282
+ this.flows.delete(id);
283
+ if (!f)
284
+ throw new Error(`unknown pending authorization "${id}"`);
285
+ if (Date.now() > f.expires)
286
+ throw new Error("pending authorization expired");
287
+ await this.complete(f, callbackUrl);
288
+ return f.retry();
289
+ }
290
+ async complete(f, callbackUrl) {
291
+ const q = new URL(callbackUrl).searchParams;
292
+ // RFC 9207 §2.4 table (spec: Authorization Response Validation) — before anything else, error responses included.
293
+ if (q.has("iss")) {
294
+ if (q.get("iss") !== f.issuer)
295
+ throw new Error("authorization response iss mismatch (possible mix-up attack)"); // no normalization
296
+ }
297
+ else if (f.issSupported)
298
+ throw new Error("authorization response missing iss");
299
+ if ((q.get("state") ?? "") !== f.state)
300
+ throw new Error("authorization response state mismatch");
301
+ const err = q.get("error");
302
+ if (err)
303
+ throw new Error(`authorization denied: ${err}`);
304
+ const t = await this.tokenRequest(f.tokenEndpoint, new URLSearchParams({
305
+ grant_type: "authorization_code", code: q.get("code") ?? "", redirect_uri: f.redirect,
306
+ client_id: f.clientId, code_verifier: f.verifier, resource: f.resource,
307
+ }), f.issuer, f.resource, f.clientId, f.scopes);
308
+ await this.store.save(f.resource, t);
309
+ }
310
+ async tokenRequest(ep, form, issuer, resource, clientId, scopes) {
311
+ let method = this.tokenAuthMethod;
312
+ if (clientId === this.clientMetadataUrl || !this.clientSecret)
313
+ method = "none"; // CIMD + secretless clients are public
314
+ else if (!method)
315
+ method = "client_secret_basic";
316
+ if (method === "client_secret_post")
317
+ form.set("client_secret", this.clientSecret);
318
+ const headers = {
319
+ "Content-Type": "application/x-www-form-urlencoded",
320
+ Accept: "application/json", // GitHub answers form-encoded without it
321
+ };
322
+ if (method === "client_secret_basic") {
323
+ form.delete("client_id"); // RFC 6749 §2.3.1: authenticate with one method only
324
+ headers.Authorization = "Basic " + Buffer.from(formEscape(clientId) + ":" + formEscape(this.clientSecret)).toString("base64");
325
+ }
326
+ const resp = await this.http({ method: "POST", url: ep, headers, body: form.toString() });
327
+ let tr;
328
+ try {
329
+ tr = JSON.parse(resp.body);
330
+ }
331
+ catch {
332
+ if ((resp.headers["content-type"] ?? "").startsWith("application/x-www-form-urlencoded")) {
333
+ // RFC 6749 §5.1 mandates JSON, but some ASes (GitHub) still default to form encoding.
334
+ tr = Object.fromEntries(new URLSearchParams(resp.body));
335
+ }
336
+ }
337
+ if (!tr || resp.status !== 200 || !tr.access_token) {
338
+ throw new Error(`token endpoint: HTTP ${resp.status} ${typeof tr?.error === "string" ? tr.error : ""}`); // never include the body
339
+ }
340
+ const t = new Token({ accessToken: String(tr.access_token), refreshToken: String(tr.refresh_token ?? ""), issuer, resource, clientId, scopes });
341
+ if (typeof tr.scope === "string" && tr.scope)
342
+ t.scopes = tr.scope.split(/\s+/).filter(Boolean);
343
+ const ei = Number(tr.expires_in);
344
+ if (ei > 0)
345
+ t.expiresAt = Date.now() + ei * 1000;
346
+ return t;
347
+ }
348
+ /** PRM: header URL if present, else path-inserted well-known, else root (spec: PRM Discovery Requirements). */
349
+ async fetchPRM(resource, hinted) {
350
+ let cands = [hinted];
351
+ if (!hinted) {
352
+ const u = new URL(resource);
353
+ const root = `${u.protocol}//${u.host}/.well-known/oauth-protected-resource`;
354
+ const p = u.pathname.replace(/\/$/, "");
355
+ cands = p ? [root + p, root] : [root];
356
+ }
357
+ let last;
358
+ for (const c of cands) {
359
+ let m;
360
+ try {
361
+ m = await getJSON(this.http, c);
362
+ }
363
+ catch (e) {
364
+ last = e;
365
+ continue;
366
+ }
367
+ if (m.resource !== resource)
368
+ throw new Error(`PRM resource ${m.resource} does not match ${resource}`); // RFC 9728 §3.3
369
+ return m;
370
+ }
371
+ throw new Error(`protected resource metadata: ${last?.message ?? last}`);
372
+ }
373
+ }
374
+ function expired(t) {
375
+ return t.expiresAt > 0 && Date.now() > t.expiresAt - 10_000;
376
+ }
377
+ /** RFC 8414 then OIDC endpoints in the spec's order, with an issuer equality check. */
378
+ export async function discoverAS(httpFn, issuer) {
379
+ const u = new URL(issuer);
380
+ const base = `${u.protocol}//${u.host}`;
381
+ const p = u.pathname.replace(/\/$/, "");
382
+ const cands = p
383
+ ? [base + "/.well-known/oauth-authorization-server" + p, base + "/.well-known/openid-configuration" + p, base + p + "/.well-known/openid-configuration"]
384
+ : [base + "/.well-known/oauth-authorization-server", base + "/.well-known/openid-configuration"];
385
+ for (const c of cands) {
386
+ let m;
387
+ try {
388
+ m = await getJSON(httpFn, c);
389
+ }
390
+ catch {
391
+ continue;
392
+ }
393
+ if (m.issuer !== issuer)
394
+ throw new Error(`AS metadata issuer ${m.issuer} != ${issuer}`);
395
+ return m;
396
+ }
397
+ throw new Error(`no authorization server metadata for ${issuer}`);
398
+ }
399
+ /** Parses `Bearer k="v", k2=v2` (RFC 6750 §3 / RFC 9110 auth-param). */
400
+ export function parseChallenge(h) {
401
+ const out = {};
402
+ h = h.trim();
403
+ if (h.startsWith("Bearer"))
404
+ h = h.slice(6);
405
+ h = h.trim();
406
+ while (h) {
407
+ const eq = h.indexOf("=");
408
+ if (eq < 0)
409
+ break;
410
+ const k = h.slice(0, eq).trim().toLowerCase();
411
+ h = h.slice(eq + 1).trim();
412
+ let v;
413
+ if (h.startsWith('"')) {
414
+ let end = 1;
415
+ while (end < h.length && h[end] !== '"') {
416
+ if (h[end] === "\\")
417
+ end++;
418
+ end++;
419
+ }
420
+ v = h.slice(1, Math.min(end, h.length)).replaceAll('\\"', '"');
421
+ h = h.slice(Math.min(end + 1, h.length));
422
+ }
423
+ else {
424
+ let end = h.indexOf(",");
425
+ if (end < 0)
426
+ end = h.length;
427
+ v = h.slice(0, end).trim();
428
+ h = h.slice(end);
429
+ }
430
+ out[k] = v;
431
+ h = h.trim();
432
+ if (h.startsWith(","))
433
+ h = h.slice(1);
434
+ h = h.trim();
435
+ }
436
+ return out;
437
+ }
438
+ function strings(v) {
439
+ return Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
440
+ }
441
+ function formEscape(s) {
442
+ return new URLSearchParams({ x: s }).toString().slice(2);
443
+ }
444
+ export function randStr(n) {
445
+ return randomBytes(n).toString("base64url");
446
+ }
@@ -0,0 +1,48 @@
1
+ import { type HttpFn, type Obj } from "./protocol.js";
2
+ import type { OAuth } from "./auth.js";
3
+ /** The transport behind a stdio client (stdio.ts). */
4
+ export interface Transport {
5
+ roundTrip(id: number, body: string, timeoutMs: number, signal?: AbortSignal): Promise<Obj>;
6
+ close(): Promise<void>;
7
+ }
8
+ export interface CallOptions {
9
+ timeoutMs?: number;
10
+ signal?: AbortSignal;
11
+ }
12
+ export declare class Mcp2026Client {
13
+ readonly url: string;
14
+ info: Obj;
15
+ caps: Obj;
16
+ http: HttpFn;
17
+ headers: Record<string, string>;
18
+ auth?: OAuth;
19
+ timeoutMs: number;
20
+ transport?: Transport;
21
+ private nextId;
22
+ constructor(url: string, o?: {
23
+ headers?: Record<string, string>;
24
+ auth?: OAuth;
25
+ timeoutMs?: number;
26
+ transport?: Transport;
27
+ });
28
+ /** Sends one request; params gets the per-request _meta merged in. Throws RpcError on a JSON-RPC error. */
29
+ call(method: string, params?: Obj, o?: CallOptions): Promise<Obj>;
30
+ private httpRoundTrip;
31
+ private post;
32
+ /** tools/list (one page — no nextCursor on the 2026 path, §2.9). Returns the tools and the ttlMs hint. */
33
+ listTools(o?: CallOptions): Promise<{
34
+ tools: Obj[];
35
+ ttlMs?: number;
36
+ }>;
37
+ /**
38
+ * One remote skill (§2.7) as a data skill: skills/get, then resources/read of its SKILL.md (sha256
39
+ * verified). Every resource URI must be inside the skill. Returns the raw SKILL.md text and the
40
+ * other resources' relative paths.
41
+ */
42
+ fetchSkill(uri: string, o?: CallOptions): Promise<{
43
+ name: string;
44
+ markdown: string;
45
+ resources: string[];
46
+ }>;
47
+ close(): Promise<void>;
48
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Stateless MCP 2026-07-28 client (SPEC §2.2–§2.7): Streamable HTTP or stdio, one API.
3
+ * Never sends `initialize`; no session; never re-sends a request except after a pre-dispatch
4
+ * 401/403 auth rejection (§2.6).
5
+ */
6
+ import { createHash } from "node:crypto";
7
+ import { defaultHttp, encodeHeader, HttpStatusError, META_CLIENT_CAPS, META_CLIENT_INFO, META_VERSION, NotJsonRpcError, PROTOCOL_VERSION, RpcError, DEFAULT_TIMEOUT_MS, } from "./protocol.js";
8
+ import { parseSkillURI } from "./skills.js";
9
+ export class Mcp2026Client {
10
+ url;
11
+ info = { name: "toolnexus", version: "0.1.0" };
12
+ caps = { elicitation: { form: {} } };
13
+ http = defaultHttp;
14
+ headers;
15
+ auth;
16
+ timeoutMs;
17
+ transport;
18
+ nextId = 0;
19
+ constructor(url, o = {}) {
20
+ this.url = url;
21
+ this.headers = o.headers ?? {};
22
+ this.auth = o.auth;
23
+ this.timeoutMs = o.timeoutMs ?? DEFAULT_TIMEOUT_MS;
24
+ this.transport = o.transport;
25
+ }
26
+ /** Sends one request; params gets the per-request _meta merged in. Throws RpcError on a JSON-RPC error. */
27
+ async call(method, params = {}, o = {}) {
28
+ params = { ...params, _meta: { ...(params._meta ?? {}), [META_VERSION]: PROTOCOL_VERSION, [META_CLIENT_CAPS]: this.caps, [META_CLIENT_INFO]: this.info } };
29
+ const id = ++this.nextId;
30
+ const body = JSON.stringify({ jsonrpc: "2.0", id, method, params });
31
+ const timeoutMs = o.timeoutMs ?? this.timeoutMs;
32
+ const msg = this.transport ? await this.transport.roundTrip(id, body, timeoutMs, o.signal) : await this.httpRoundTrip(method, params, body, timeoutMs, o.signal);
33
+ if (msg.error != null) {
34
+ const e = msg.error;
35
+ const err = new RpcError(Number(e.code), String(e.message ?? ""), e.data);
36
+ if (msg.__status)
37
+ err.httpStatus = msg.__status;
38
+ throw err;
39
+ }
40
+ const r = msg.result;
41
+ if (!r || typeof r !== "object" || Array.isArray(r))
42
+ throw new NotJsonRpcError("response has neither result nor error");
43
+ if (!("resultType" in r))
44
+ r.resultType = "complete";
45
+ if (r.resultType !== "complete" && r.resultType !== "input_required")
46
+ throw new Error(`unsupported resultType "${String(r.resultType)}"`);
47
+ return r;
48
+ }
49
+ async httpRoundTrip(method, params, body, timeoutMs, signal) {
50
+ let resp;
51
+ for (let attempt = 0;; attempt++) {
52
+ const sent = this.auth ? await this.auth.bearer(this.url) : "";
53
+ resp = await this.post(method, params, body, sent, timeoutMs, signal); // a network error is never retried
54
+ const www = resp.headers["www-authenticate"] ?? "";
55
+ if (!this.auth || (resp.status !== 401 && resp.status !== 403) || !www)
56
+ break;
57
+ if (attempt >= 3)
58
+ throw new Error(`authorization failed: HTTP ${resp.status} after ${attempt + 1} attempts`);
59
+ await this.auth.challenge(this.url, resp.status, www, sent, () => this.call(method, params, { timeoutMs, signal }));
60
+ }
61
+ let msg;
62
+ if ((resp.headers["content-type"] ?? "").startsWith("text/event-stream")) {
63
+ // The last data: payload carrying a result or error is the final response.
64
+ for (const line of resp.body.split(/\r?\n/)) {
65
+ if (!line.startsWith("data:"))
66
+ continue;
67
+ try {
68
+ const m = JSON.parse(line.slice(5));
69
+ if (m && (m.result !== undefined || m.error !== undefined))
70
+ msg = m;
71
+ }
72
+ catch {
73
+ /* not JSON: ignore */
74
+ }
75
+ }
76
+ }
77
+ else {
78
+ try {
79
+ msg = JSON.parse(resp.body);
80
+ }
81
+ catch {
82
+ msg = undefined;
83
+ }
84
+ }
85
+ const ok = resp.status >= 200 && resp.status < 300;
86
+ const isMsg = !!msg && typeof msg === "object" && (msg.result != null || (msg.error != null && typeof msg.error === "object" && typeof msg.error.code === "number"));
87
+ if (!isMsg) {
88
+ if (!ok)
89
+ throw new HttpStatusError(resp.status);
90
+ throw new NotJsonRpcError(`HTTP ${resp.status}: response is not a JSON-RPC response`);
91
+ }
92
+ if (!ok && msg.error == null)
93
+ throw new HttpStatusError(resp.status);
94
+ return { ...msg, __status: ok ? undefined : resp.status };
95
+ }
96
+ post(method, params, body, bearer, timeoutMs, signal) {
97
+ const h = {
98
+ ...this.headers,
99
+ "Content-Type": "application/json",
100
+ Accept: "application/json, text/event-stream",
101
+ "MCP-Protocol-Version": PROTOCOL_VERSION,
102
+ "Mcp-Method": method,
103
+ };
104
+ if (method === "tools/call" || method === "prompts/get")
105
+ h["Mcp-Name"] = encodeHeader(String(params.name ?? ""));
106
+ if (method === "resources/read")
107
+ h["Mcp-Name"] = encodeHeader(String(params.uri ?? ""));
108
+ if (bearer)
109
+ h.Authorization = "Bearer " + bearer;
110
+ return this.http({ method: "POST", url: this.url, headers: h, body, timeoutMs, signal });
111
+ }
112
+ /** tools/list (one page — no nextCursor on the 2026 path, §2.9). Returns the tools and the ttlMs hint. */
113
+ async listTools(o = {}) {
114
+ const r = await this.call("tools/list", {}, o);
115
+ const tools = (Array.isArray(r.tools) ? r.tools : []).filter((t) => t && typeof t === "object" && typeof t.name === "string");
116
+ return { tools, ttlMs: typeof r.ttlMs === "number" ? r.ttlMs : undefined };
117
+ }
118
+ /**
119
+ * One remote skill (§2.7) as a data skill: skills/get, then resources/read of its SKILL.md (sha256
120
+ * verified). Every resource URI must be inside the skill. Returns the raw SKILL.md text and the
121
+ * other resources' relative paths.
122
+ */
123
+ async fetchSkill(uri, o = {}) {
124
+ const { name } = parseSkillURI(uri);
125
+ const r = await this.call("skills/get", { uri }, o);
126
+ const res = Array.isArray(r.skill?.resources) ? r.skill.resources : [];
127
+ const others = [];
128
+ let md;
129
+ for (const m of res) {
130
+ const fu = String(m?.uri ?? "");
131
+ let rel;
132
+ try {
133
+ const p = parseSkillURI(fu);
134
+ if (p.name !== name)
135
+ throw new Error();
136
+ rel = p.rel;
137
+ }
138
+ catch {
139
+ throw new Error(`refusing skill resource "${fu}": outside skill ${name}`);
140
+ }
141
+ if (rel === "SKILL.md")
142
+ md = m;
143
+ else
144
+ others.push(rel);
145
+ }
146
+ const mdUri = md ? String(md.uri) : uri;
147
+ const rr = await this.call("resources/read", { uri: mdUri }, o);
148
+ const cs = Array.isArray(rr.contents) ? rr.contents : [];
149
+ if (cs.length === 0)
150
+ throw new Error(`${mdUri}: empty resources/read`);
151
+ const text = typeof cs[0]?.text === "string" ? cs[0].text : "";
152
+ const d = typeof md?.digest === "string" ? md.digest : "";
153
+ if (d && d !== "sha256:" + createHash("sha256").update(text).digest("hex"))
154
+ throw new Error(`${mdUri}: digest mismatch`);
155
+ return { name, markdown: text, resources: others.sort() };
156
+ }
157
+ async close() {
158
+ await this.transport?.close();
159
+ }
160
+ }