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
package/dist/mcp.js CHANGED
@@ -11,6 +11,9 @@ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
11
11
  import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
12
12
  import { sanitize } from "./types.js";
13
13
  import { summarizePart } from "./content.js";
14
+ import { probe, newOAuth, defaultTokenStore } from "./mcp2026/source.js";
15
+ import { PendingError } from "./mcp2026/auth.js";
16
+ export { Token, MemoryTokenStore } from "./mcp2026/auth.js";
14
17
  // ---------------------------------------------------------------------------
15
18
  // MCP elicitation bridge (§10). A server can ask US for input mid-`tools/call`
16
19
  // (a reverse-request). We map it onto the one `waitFor`: form mode → kind:"input",
@@ -224,6 +227,22 @@ async function paginateTools(client, timeout) {
224
227
  }
225
228
  throw new Error(`MCP list exceeded ${MAX_LIST_PAGES} pages`);
226
229
  }
230
+ /** §2 `tools/call` result → ToolResult (shared by the 2026 path and the frozen fallback). */
231
+ export function mapCallToolResult(result, server) {
232
+ // Parts are collected BEFORE any branch returns: the isError and structuredContent
233
+ // short-circuits both read `content[]` on the way past, so neither reintroduces the
234
+ // silent drop this mapping exists to remove (§2).
235
+ const parts = mcpContentToParts(result.content);
236
+ const withParts = (r) => (parts ? { ...r, parts } : r);
237
+ if (result.isError) {
238
+ return withParts({ output: formatToolErrorContent(result.content), isError: true, metadata: { server } });
239
+ }
240
+ if (result.structuredContent !== undefined && result.structuredContent !== null) {
241
+ return withParts({ output: JSON.stringify(result.structuredContent), isError: false, metadata: { server } });
242
+ }
243
+ const text = joinTextContent(result.content);
244
+ return withParts({ output: text || describeMcpParts(parts), isError: false, metadata: { server } });
245
+ }
227
246
  function convertTool(server, def, client, timeout) {
228
247
  const inputSchema = {
229
248
  ...def.inputSchema,
@@ -244,19 +263,7 @@ function convertTool(server, def, client, timeout) {
244
263
  // `resetTimeoutOnProgress` never fires (add-mcp-progress-timeout). Notifications for a
245
264
  // token that matches no in-flight request are dropped by the SDK, so they never reset.
246
265
  { onprogress: () => { }, resetTimeoutOnProgress: true, signal: ctx?.signal, timeout: ctx?.timeout ?? timeout });
247
- // Parts are collected BEFORE any branch returns: the isError and structuredContent
248
- // short-circuits both read `content[]` on the way past, so neither reintroduces the
249
- // silent drop this mapping exists to remove (§2).
250
- const parts = mcpContentToParts(result.content);
251
- const withParts = (r) => (parts ? { ...r, parts } : r);
252
- if (result.isError) {
253
- return withParts({ output: formatToolErrorContent(result.content), isError: true, metadata: { server } });
254
- }
255
- if (result.structuredContent !== undefined && result.structuredContent !== null) {
256
- return withParts({ output: JSON.stringify(result.structuredContent), isError: false, metadata: { server } });
257
- }
258
- const text = joinTextContent(result.content);
259
- return withParts({ output: text || describeMcpParts(parts), isError: false, metadata: { server } });
266
+ return mapCallToolResult(result, server);
260
267
  }
261
268
  catch (e) {
262
269
  return { output: e instanceof Error ? e.message : String(e), isError: true, metadata: { server } };
@@ -276,6 +283,15 @@ export function mcpHints(a) {
276
283
  h.destructive = false;
277
284
  return h;
278
285
  }
286
+ function sdkConn(client, timeout) {
287
+ return {
288
+ listResources: async (signal) => (await client.listResources({}, { timeout, signal })).resources ?? [],
289
+ readResource: async (uri, signal) => (await client.readResource({ uri }, { timeout, signal })).contents ?? [],
290
+ };
291
+ }
292
+ function modernConn(s) {
293
+ return { listResources: (signal) => s.listResources(signal), readResource: (uri, signal) => s.readResource(uri, signal) };
294
+ }
279
295
  function resourceTools(servers) {
280
296
  const ro = { readOnly: true, destructive: false, openWorld: false };
281
297
  const names = () => [...servers.keys()].sort();
@@ -312,8 +328,7 @@ function resourceTools(servers) {
312
328
  for (const name of targets) {
313
329
  const c = conn(name);
314
330
  try {
315
- const res = await c.client.listResources({}, { timeout: c.timeout, signal: ctx?.signal });
316
- for (const r of res.resources ?? []) {
331
+ for (const r of await c.listResources(ctx?.signal)) {
317
332
  let line = `${name} ${r.uri}`;
318
333
  if (r.name)
319
334
  line += ` — ${r.name}`;
@@ -361,9 +376,8 @@ function resourceTools(servers) {
361
376
  if (!uri)
362
377
  return { output: "read_mcp_resource: uri is required", isError: true };
363
378
  try {
364
- const res = await c.client.readResource({ uri }, { timeout: c.timeout, signal: ctx?.signal });
365
379
  const parts = [];
366
- for (const rc of res.contents ?? []) {
380
+ for (const rc of await c.readResource(uri, ctx?.signal)) {
367
381
  if (typeof rc.text === "string")
368
382
  parts.push(rc.text);
369
383
  else if (typeof rc.blob === "string")
@@ -390,16 +404,17 @@ function newMcpClient(waitFor) {
390
404
  return client;
391
405
  }
392
406
  /** Connect an existing client's transport (remote streamable-HTTP with SSE fallback, or local stdio). */
393
- async function connectTransport(client, cfg) {
407
+ async function connectTransport(client, cfg, auth) {
394
408
  if (isRemote(cfg)) {
395
409
  const url = new URL(cfg.url);
396
410
  const expanded = expandEnvHeaders(cfg.headers);
397
411
  const requestInit = expanded ? { headers: expanded } : undefined;
412
+ const fetch = auth ? bearerFetch(cfg.url, auth) : undefined;
398
413
  try {
399
- await client.connect(new StreamableHTTPClientTransport(url, { requestInit }));
414
+ await client.connect(new StreamableHTTPClientTransport(url, { requestInit, ...(fetch ? { fetch } : {}) }));
400
415
  }
401
416
  catch {
402
- await client.connect(new SSEClientTransport(url, { requestInit }));
417
+ await client.connect(new SSEClientTransport(url, { requestInit, ...(fetch ? { fetch } : {}) }));
403
418
  }
404
419
  }
405
420
  else {
@@ -414,6 +429,66 @@ async function connectTransport(client, cfg) {
414
429
  }));
415
430
  }
416
431
  }
432
+ /**
433
+ * §2A.1a frozen fallback + OAuth: the SDK transport's fetch attaches the current bearer token
434
+ * (refreshing as needed); a 401/403 challenge runs the same OAuth flow once, then the request is
435
+ * re-sent. A pause is answered through waitFor, else the request fails ("authorization pending").
436
+ */
437
+ function bearerFetch(resource, a) {
438
+ return async (input, init) => {
439
+ for (let attempt = 0;; attempt++) {
440
+ const headers = new Headers(init?.headers);
441
+ const tok = await a.oauth.bearer(resource);
442
+ if (tok)
443
+ headers.set("Authorization", "Bearer " + tok);
444
+ const res = await globalThis.fetch(input, { ...init, headers });
445
+ const www = res.headers.get("www-authenticate") ?? "";
446
+ if (attempt > 0 || (res.status !== 401 && res.status !== 403) || !www)
447
+ return res;
448
+ try {
449
+ await a.oauth.challenge(resource, res.status, www, tok, async () => ({}));
450
+ }
451
+ catch (e) {
452
+ if (!(e instanceof PendingError))
453
+ throw e;
454
+ if (!a.waitFor)
455
+ throw new Error("authorization pending");
456
+ const ans = await a.waitFor({ ...e.pending });
457
+ if (!ans?.ok)
458
+ throw new Error(`authorization denied: ${ans?.reason ?? "cancelled"}`);
459
+ const cb = ans.data?.callbackUrl;
460
+ if (typeof cb !== "string")
461
+ throw new Error("authorization answer carries no data.callbackUrl");
462
+ await a.oauth.resume(e.pending.id, cb);
463
+ }
464
+ }
465
+ };
466
+ }
467
+ /**
468
+ * Discover first (§2.2): a 2026 server is used directly; an old one (§2.2a) gets the frozen SDK
469
+ * path, with the OAuth bearer attached for remote servers. Throws ⇒ the server failed.
470
+ */
471
+ async function connectServer(name, cfg, timeout, opts, store, sdkClient, onSdk) {
472
+ const o = { waitFor: opts.waitFor, tokenStore: store, openUrl: opts.openUrl, signal: opts.signal, disableTools: opts.disableTools };
473
+ const remote = isRemote(cfg);
474
+ const oauth = remote ? newOAuth(cfg.auth, o, store) : undefined;
475
+ const p = await probe({
476
+ name, timeout, tools: cfg.tools, toolSearch: cfg.toolSearch === true, description: cfg.description,
477
+ remote: remote ? { url: cfg.url, headers: expandEnvHeaders(cfg.headers), oauth: oauth } : undefined,
478
+ local: remote ? undefined : {
479
+ argv: cfg.command,
480
+ env: { ...process.env, ...(cfg.environment ?? cfg.env ?? {}) },
481
+ cwd: cfg.cwd,
482
+ },
483
+ }, o);
484
+ if (p.kind === "modern")
485
+ return p;
486
+ const client = sdkClient();
487
+ onSdk(client);
488
+ await raceTimeout(connectTransport(client, cfg, oauth ? { oauth, waitFor: opts.waitFor } : undefined), timeout, opts.signal);
489
+ const defs = await raceTimeout(paginateTools(client, timeout), timeout, opts.signal);
490
+ return { kind: "sdk", client, defs };
491
+ }
417
492
  /** Connect to every enabled server, list + convert tools. Failures are isolated.
418
493
  * §2 Gap 3: an optional `signal` bounds each connect/list by the server timeout and aborts the
419
494
  * whole load promptly on parent cancellation. */
@@ -423,6 +498,8 @@ export async function loadMcp(input, opts) {
423
498
  const status = {};
424
499
  const clients = [];
425
500
  const servers = new Map();
501
+ const skills = [];
502
+ const store = defaultTokenStore({ tokenStore: opts?.tokenStore });
426
503
  const waitFor = opts?.waitFor;
427
504
  const signal = opts?.signal;
428
505
  const onServer = opts?.onServer;
@@ -447,23 +524,31 @@ export async function loadMcp(input, opts) {
447
524
  return;
448
525
  }
449
526
  const timeout = cfg.timeout ?? DEFAULT_TIMEOUT;
450
- // Create the client up front so a timed-out/aborted connect still has a closeable handle
451
- // (its stdio child is killed by close() — never orphaned).
452
- const client = newMcpClient(waitFor);
527
+ // The fallback SDK client is created before its connect so a timed-out/aborted connect
528
+ // still has a closeable handle (its stdio child is killed by close() — never orphaned).
529
+ let client;
453
530
  try {
454
- await raceTimeout(connectTransport(client, cfg), timeout, signal);
455
- const defs = await raceTimeout(paginateTools(client, timeout), timeout, signal);
456
- const kept = applyToolsFilter(name, defs, cfg.tools);
457
- const converted = kept.map((def) => convertTool(name, def, client, timeout));
531
+ const c = await connectServer(name, cfg, timeout, opts ?? {}, store, () => newMcpClient(waitFor), (cl) => (client = cl));
532
+ let converted;
533
+ if (c.kind === "modern") {
534
+ converted = c.server.tools();
535
+ skills.push(...c.server.skills);
536
+ clients.push(c.server);
537
+ servers.set(name, modernConn(c.server));
538
+ }
539
+ else {
540
+ const kept = applyToolsFilter(name, c.defs, cfg.tools);
541
+ converted = kept.map((def) => convertTool(name, def, c.client, timeout));
542
+ clients.push(c.client);
543
+ servers.set(name, sdkConn(c.client, timeout));
544
+ }
458
545
  tools.push(...converted);
459
- clients.push(client);
460
- servers.set(name, { client, timeout });
461
546
  status[name] = "connected";
462
547
  report({ name, status: "connected", tools: converted });
463
548
  }
464
549
  catch (e) {
465
550
  status[name] = "failed";
466
- await client.close().catch(() => { });
551
+ await client?.close().catch(() => { });
467
552
  // Parent cancellation/deadline aborts the whole load (§2 Gap 3, A1); a per-server timeout
468
553
  // within budget isolates just this server.
469
554
  if (signal?.aborted)
@@ -493,6 +578,7 @@ export async function loadMcp(input, opts) {
493
578
  await Promise.all(clients.map((c) => c.close().catch(() => { })));
494
579
  },
495
580
  resourceTools: () => resourceTools(servers),
581
+ skills,
496
582
  };
497
583
  }
498
584
  /**
@@ -504,17 +590,20 @@ export async function listMcpTools(input, opts) {
504
590
  const toolsByServer = {};
505
591
  const status = {};
506
592
  const signal = opts?.signal;
593
+ const store = defaultTokenStore({ tokenStore: opts?.tokenStore });
507
594
  await Promise.all(Object.entries(config).map(async ([name, cfg]) => {
508
595
  if (!isEnabled(cfg)) {
509
596
  status[name] = "disabled";
510
597
  return;
511
598
  }
512
599
  const timeout = cfg.timeout ?? DEFAULT_TIMEOUT;
513
- // Client created up front so an aborted/timed-out connect is still closed (no orphaned child).
514
- const client = newMcpClient();
600
+ // The fallback client is created before its connect so an aborted/timed-out connect is still closed.
601
+ let client;
515
602
  try {
516
- await raceTimeout(connectTransport(client, cfg), timeout, signal);
517
- const defs = await raceTimeout(paginateTools(client, timeout), timeout, signal);
603
+ const c = await connectServer(name, cfg, timeout, { ...opts, waitFor: opts?.waitFor }, store, () => newMcpClient(), (cl) => (client = cl));
604
+ const defs = c.kind === "modern" ? c.server.rawDefs() : c.defs;
605
+ if (c.kind === "modern")
606
+ await c.server.close().catch(() => { });
518
607
  toolsByServer[name] = defs.map((d) => ({
519
608
  name: d.name,
520
609
  description: d.description ?? "",
@@ -529,13 +618,13 @@ export async function listMcpTools(input, opts) {
529
618
  catch (e) {
530
619
  status[name] = "failed";
531
620
  if (signal?.aborted) {
532
- await client.close().catch(() => { });
621
+ await client?.close().catch(() => { });
533
622
  throw e;
534
623
  }
535
624
  console.warn(`[toolnexus] MCP server "${name}" failed: ${e instanceof Error ? e.message : String(e)}`);
536
625
  }
537
626
  finally {
538
- await client.close().catch(() => { });
627
+ await client?.close().catch(() => { });
539
628
  }
540
629
  }));
541
630
  return { tools: toolsByServer, status };
@@ -0,0 +1,137 @@
1
+ import { type HttpFn, type Obj } from "./protocol.js";
2
+ declare const inspect: unique symbol;
3
+ /** Bound to the issuer that minted it and the resource it is for. Never log it (it prints redacted). */
4
+ export declare class Token {
5
+ accessToken: string;
6
+ refreshToken: string;
7
+ /** ms since epoch; 0 = no expiry. */
8
+ expiresAt: number;
9
+ scopes: string[];
10
+ issuer: string;
11
+ clientId: string;
12
+ /** The protected resource (server URI) the token is bound to. */
13
+ resource: string;
14
+ constructor(init?: Partial<Token>);
15
+ toString(): string;
16
+ toJSON(): string;
17
+ [inspect](): string;
18
+ }
19
+ /** Persists tokens per resource. Implementations must keep them confidential. Sync or async. */
20
+ export interface TokenStore {
21
+ load(resource: string): Token | null | undefined | Promise<Token | null | undefined>;
22
+ save(resource: string, t: Token): void | Promise<void>;
23
+ delete(resource: string): void | Promise<void>;
24
+ }
25
+ export declare class MemoryTokenStore implements TokenStore {
26
+ private m;
27
+ load(r: string): Token | undefined;
28
+ save(r: string, t: Token): void;
29
+ delete(r: string): void;
30
+ }
31
+ /** Mirrors toolnexus SPEC §10 Request (kind "authorization"). */
32
+ export interface Pending {
33
+ id: string;
34
+ kind: "authorization";
35
+ prompt: string;
36
+ url: string;
37
+ expiresAt: string;
38
+ }
39
+ /** Thrown instead of blocking when the authorizer pauses. Call oauth.resume(id, callbackURL). */
40
+ export declare class PendingError extends Error {
41
+ pending: Pending;
42
+ constructor(p: Pending);
43
+ }
44
+ /** The Pending inside err, or undefined. */
45
+ export declare function pendingOf(err: unknown): Pending | undefined;
46
+ /** What a pausing AuthSession.wait throws. */
47
+ export declare const PAUSE: Error;
48
+ /** Decides how the user reaches the authorization URL and how the callback comes back. */
49
+ export interface Authorizer {
50
+ begin(): Promise<AuthSession>;
51
+ }
52
+ export interface AuthSession {
53
+ redirectUri: string;
54
+ /** Resolves with the full callback URL, or throws PAUSE to hand a Pending to the caller. */
55
+ wait(p: Pending): Promise<string>;
56
+ close(): void | Promise<void>;
57
+ }
58
+ /** Never blocks: every authorization becomes a Pending. */
59
+ export declare class PauseAuthorizer implements Authorizer {
60
+ redirect: string;
61
+ constructor(o: {
62
+ redirect: string;
63
+ });
64
+ begin(): Promise<AuthSession>;
65
+ }
66
+ /** Opens a browser and listens on 127.0.0.1:<random>/callback (RFC 8252 §7.3). */
67
+ export declare class LoopbackAuthorizer implements Authorizer {
68
+ open: (url: string) => unknown | Promise<unknown>;
69
+ timeoutMs: number;
70
+ constructor(o?: {
71
+ open?: (url: string) => unknown | Promise<unknown>;
72
+ });
73
+ begin(): Promise<AuthSession>;
74
+ }
75
+ export interface OAuthOptions {
76
+ authorizer?: Authorizer;
77
+ clientId?: string;
78
+ clientSecret?: string;
79
+ tokenAuthMethod?: "" | "none" | "client_secret_basic" | "client_secret_post";
80
+ /** Drops the RFC 8707 `resource` from refresh_token requests (Entra rejects it; python-sdk #2578). */
81
+ omitRefreshResource?: boolean;
82
+ store?: TokenStore;
83
+ clientIds?: Record<string, string>;
84
+ clientMetadataUrl?: string;
85
+ http?: HttpFn;
86
+ allowHttp?: boolean;
87
+ }
88
+ interface AuthFlow {
89
+ resource: string;
90
+ issuer: string;
91
+ clientId: string;
92
+ redirect: string;
93
+ verifier: string;
94
+ state: string;
95
+ tokenEndpoint: string;
96
+ issSupported: boolean;
97
+ scopes: string[];
98
+ expires: number;
99
+ retry: () => Promise<Obj>;
100
+ }
101
+ /** The client-side auth engine; attach it as client.auth. */
102
+ export declare class OAuth {
103
+ authorizer: Authorizer;
104
+ clientId: string;
105
+ clientSecret: string;
106
+ tokenAuthMethod: string;
107
+ omitRefreshResource: boolean;
108
+ store: TokenStore;
109
+ clientIds: Record<string, string>;
110
+ clientMetadataUrl: string;
111
+ http: HttpFn;
112
+ allowHttp: boolean;
113
+ private authMu;
114
+ private issuer;
115
+ /** @internal paused flows by pending id (exposed for tests) */
116
+ flows: Map<string, AuthFlow>;
117
+ constructor(o?: OAuthOptions);
118
+ /** The token to send for resource, refreshing if expired. "" = send none. */
119
+ bearer(resource: string): Promise<string>;
120
+ private refresh;
121
+ /** @internal Handles a 401/403. Resolves = retry the request now; throws PendingError = paused. */
122
+ challenge(resource: string, status: number, www: string, sent: string, retry: () => Promise<Obj>): Promise<void>;
123
+ /** @internal */
124
+ setRetry(id: string, f: () => Promise<Obj>): void;
125
+ /** Finishes a paused authorization and re-runs the original operation, returning its result. */
126
+ resume(id: string, callbackUrl: string): Promise<Obj>;
127
+ private complete;
128
+ private tokenRequest;
129
+ /** PRM: header URL if present, else path-inserted well-known, else root (spec: PRM Discovery Requirements). */
130
+ fetchPRM(resource: string, hinted: string): Promise<Obj>;
131
+ }
132
+ /** RFC 8414 then OIDC endpoints in the spec's order, with an issuer equality check. */
133
+ export declare function discoverAS(httpFn: HttpFn, issuer: string): Promise<Obj>;
134
+ /** Parses `Bearer k="v", k2=v2` (RFC 6750 §3 / RFC 9110 auth-param). */
135
+ export declare function parseChallenge(h: string): Record<string, string>;
136
+ export declare function randStr(n: number): string;
137
+ export {};