toolnexus 0.24.0 → 0.25.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 (55) hide show
  1. package/README.md +116 -1
  2. package/dist/acp.d.ts +37 -6
  3. package/dist/acp.js +239 -52
  4. package/dist/client.d.ts +32 -8
  5. package/dist/client.js +102 -23
  6. package/dist/index.d.ts +2 -0
  7. package/dist/index.js +2 -0
  8. package/dist/mcp.d.ts +32 -7
  9. package/dist/mcp.js +125 -36
  10. package/dist/mcp2026/auth.d.ts +137 -0
  11. package/dist/mcp2026/auth.js +446 -0
  12. package/dist/mcp2026/client.d.ts +48 -0
  13. package/dist/mcp2026/client.js +160 -0
  14. package/dist/mcp2026/protocol.d.ts +54 -0
  15. package/dist/mcp2026/protocol.js +113 -0
  16. package/dist/mcp2026/skills.d.ts +5 -0
  17. package/dist/mcp2026/skills.js +25 -0
  18. package/dist/mcp2026/source.d.ts +86 -0
  19. package/dist/mcp2026/source.js +372 -0
  20. package/dist/mcp2026/stdio.d.ts +24 -0
  21. package/dist/mcp2026/stdio.js +99 -0
  22. package/dist/mcp2026/toolsearch.d.ts +5 -0
  23. package/dist/mcp2026/toolsearch.js +58 -0
  24. package/dist/mcphandler.d.ts +169 -0
  25. package/dist/mcphandler.js +1145 -0
  26. package/dist/mcpheader.d.ts +5 -0
  27. package/dist/mcpheader.js +18 -0
  28. package/dist/mcpserve.d.ts +24 -15
  29. package/dist/mcpserve.js +105 -2
  30. package/dist/mcpserverauth.d.ts +48 -0
  31. package/dist/mcpserverauth.js +203 -0
  32. package/dist/skill.d.ts +21 -0
  33. package/dist/skill.js +44 -0
  34. package/dist/toolkit.d.ts +20 -1
  35. package/dist/toolkit.js +25 -3
  36. package/dist/types.d.ts +22 -0
  37. package/package.json +4 -3
  38. package/src/acp.ts +258 -55
  39. package/src/client.ts +124 -27
  40. package/src/index.ts +2 -0
  41. package/src/mcp.ts +162 -43
  42. package/src/mcp2026/auth.ts +473 -0
  43. package/src/mcp2026/client.ts +161 -0
  44. package/src/mcp2026/protocol.ts +135 -0
  45. package/src/mcp2026/skills.ts +22 -0
  46. package/src/mcp2026/source.ts +407 -0
  47. package/src/mcp2026/stdio.ts +102 -0
  48. package/src/mcp2026/toolsearch.ts +56 -0
  49. package/src/mcphandler.ts +1252 -0
  50. package/src/mcpheader.ts +19 -0
  51. package/src/mcpserve.ts +115 -2
  52. package/src/mcpserverauth.ts +216 -0
  53. package/src/skill.ts +50 -0
  54. package/src/toolkit.ts +31 -4
  55. package/src/types.ts +23 -0
package/dist/client.d.ts CHANGED
@@ -358,12 +358,21 @@ export interface RunResult {
358
358
  export type StreamEvent = {
359
359
  type: "text";
360
360
  delta: string;
361
- } | {
361
+ }
362
+ /** Model reasoning narration (e.g. an ACP agent's `agent_thought_chunk`). Never part of
363
+ * the answer text or the RunResult. */
364
+ | {
365
+ type: "thought";
366
+ delta: string;
367
+ via?: "acp";
368
+ }
369
+ /** `via: "acp"` marks a tool the ACP agent ran itself: reported, never executed by the loop. */
370
+ | {
362
371
  type: "tool_call";
363
372
  id: string;
364
373
  name: string;
365
374
  args: Record<string, unknown>;
366
- via?: "call_tool";
375
+ via?: "call_tool" | "acp";
367
376
  } | {
368
377
  type: "tool_result";
369
378
  id: string;
@@ -371,7 +380,7 @@ export type StreamEvent = {
371
380
  output: string;
372
381
  isError: boolean;
373
382
  metadata?: Record<string, unknown>;
374
- via?: "call_tool";
383
+ via?: "call_tool" | "acp";
375
384
  } | {
376
385
  type: "usage";
377
386
  usage: Usage;
@@ -582,11 +591,22 @@ export interface InProcessResponse {
582
591
  totalTokens?: number;
583
592
  };
584
593
  }
594
+ /** A streaming in-process model: `emit` each event as it happens — `text` deltas become
595
+ * the streamed answer as they arrive; any other event (thought, an agent's own tool_call /
596
+ * tool_result) goes straight to the stream consumer and is never executed by the loop.
597
+ * Return the whole message when done (its `toolCalls`, if any, are what the loop runs). */
598
+ export type InProcessGenerateStream = (request: InProcessRequest, emit: (event: StreamEvent) => void) => InProcessResponse | Promise<InProcessResponse>;
599
+ type InProcessGenerate = (request: InProcessRequest) => InProcessResponse | Promise<InProcessResponse>;
585
600
  /** Options for {@link createInProcessClient}: every client option EXCEPT the three
586
- * that describe a wire — `baseUrl`, `apiKey` and `style` — plus `generate`. */
601
+ * that describe a wire — `baseUrl`, `apiKey` and `style` — plus `generate` and/or
602
+ * `generateStream` (at least one). */
587
603
  export type InProcessOptions = Omit<ClientOptions, "baseUrl" | "apiKey" | "style" | "fetch"> & {
588
604
  /** Your model. Receives the assembled request, returns one assistant message. */
589
- generate: (request: InProcessRequest) => InProcessResponse | Promise<InProcessResponse>;
605
+ generate?: InProcessGenerate;
606
+ /** Your model, streaming for real. With it, `stream()` is incremental; without it a
607
+ * `stream()` on a plain `generate` is refused. When both are set, `generate` serves
608
+ * non-streaming calls; alone, it serves them too (with a no-op `emit`). */
609
+ generateStream?: InProcessGenerateStream;
590
610
  };
591
611
  /**
592
612
  * Build a `fetch`-shaped adapter over a semantic `generate` function (ADR 0030):
@@ -596,9 +616,11 @@ export type InProcessOptions = Omit<ClientOptions, "baseUrl" | "apiKey" | "style
596
616
  * or the agent runtime's `inProcess` option) goes through this ONE function, so there
597
617
  * is no second copy of the request/response assembly to drift.
598
618
  *
599
- * Streaming is refused rather than faked — see the thrown error.
619
+ * Streaming with only a plain `generate` is refused rather than faked — see the thrown
620
+ * error. With `generateStream` it streams for real: each text event is written to the
621
+ * SSE body the moment it is emitted.
600
622
  */
601
- export declare function createInProcessFetch(generate: (request: InProcessRequest) => InProcessResponse | Promise<InProcessResponse>): typeof fetch;
623
+ export declare function createInProcessFetch(generate?: InProcessGenerate, generateStream?: InProcessGenerateStream): typeof fetch;
602
624
  /**
603
625
  * A client backed by a model running IN THIS PROCESS — no server, no socket, and no
604
626
  * HTTP types to construct.
@@ -609,7 +631,8 @@ export declare function createInProcessFetch(generate: (request: InProcessReques
609
631
  * The `fetch`-shaped adapter itself is {@link createInProcessFetch} — this function
610
632
  * is just that adapter wired into a `Client`.
611
633
  *
612
- * Streaming is refused rather than faked — see {@link createInProcessFetch}.
634
+ * Streaming is refused rather than faked for a plain `generate`; with `generateStream`
635
+ * it streams for real — see {@link createInProcessFetch}.
613
636
  */
614
637
  export declare function createInProcessClient(opts: InProcessOptions): Client;
615
638
  /** Stateful multi-turn conversation: each send() continues the same transcript (memory). */
@@ -625,3 +648,4 @@ export declare class Conversation {
625
648
  /** Reset the conversation memory. */
626
649
  reset(): void;
627
650
  }
651
+ export {};
package/dist/client.js CHANGED
@@ -1132,6 +1132,13 @@ export class Client {
1132
1132
  if (payload === "[DONE]")
1133
1133
  break;
1134
1134
  const j = safeParse(payload);
1135
+ // A non-text event from an in-process streaming generate rides in-band so it keeps
1136
+ // its order and arrives as it happens; it goes straight to the consumer and is never
1137
+ // executed. Honoured only on the in-process sentinel URL, never from a real server.
1138
+ if (j && j[IN_PROCESS_SIDE_KEY] && this.opts.baseUrl === IN_PROCESS_BASE_URL) {
1139
+ yield j[IN_PROCESS_SIDE_KEY];
1140
+ continue;
1141
+ }
1135
1142
  if (j?.usage)
1136
1143
  addUsage(usage, j.usage, "openai");
1137
1144
  const choice = j?.choices?.[0];
@@ -1336,6 +1343,9 @@ export function createClient(opts) {
1336
1343
  * it must be syntactically valid. `.invalid` is reserved by RFC 2606 precisely so a
1337
1344
  * name can never resolve. */
1338
1345
  const IN_PROCESS_BASE_URL = "http://in-process.invalid/v1";
1346
+ /** The in-band SSE key that carries a non-text stream event from an in-process
1347
+ * streaming generate to the consumer (honoured only on {@link IN_PROCESS_BASE_URL}). */
1348
+ const IN_PROCESS_SIDE_KEY = "toolnexus_event";
1339
1349
  /**
1340
1350
  * Build a `fetch`-shaped adapter over a semantic `generate` function (ADR 0030):
1341
1351
  * the reusable piece of {@link createInProcessClient} — assembles the wire request,
@@ -1344,14 +1354,19 @@ const IN_PROCESS_BASE_URL = "http://in-process.invalid/v1";
1344
1354
  * or the agent runtime's `inProcess` option) goes through this ONE function, so there
1345
1355
  * is no second copy of the request/response assembly to drift.
1346
1356
  *
1347
- * Streaming is refused rather than faked — see the thrown error.
1357
+ * Streaming with only a plain `generate` is refused rather than faked — see the thrown
1358
+ * error. With `generateStream` it streams for real: each text event is written to the
1359
+ * SSE body the moment it is emitted.
1348
1360
  */
1349
- export function createInProcessFetch(generate) {
1350
- if (typeof generate !== "function") {
1351
- throw new Error("toolnexus: createInProcessFetch requires a `generate` function");
1361
+ export function createInProcessFetch(generate, generateStream) {
1362
+ if (typeof generate !== "function" && typeof generateStream !== "function") {
1363
+ throw new Error("toolnexus: createInProcessFetch requires a `generate` (or `generateStream`) function");
1352
1364
  }
1353
1365
  const inProcessFetch = async (_url, init) => {
1354
1366
  const body = JSON.parse(String(init?.body ?? "{}"));
1367
+ const request = { messages: body.messages ?? [], tools: body.tools, model: body.model, body };
1368
+ if (body.stream && generateStream)
1369
+ return inProcessStream(generateStream, request);
1355
1370
  // A generate() returns a whole answer, so a stream would be one chunk pretending
1356
1371
  // to be many. Content and delta-count assertions cannot tell that from a real
1357
1372
  // stream, so refuse instead of degrading silently.
@@ -1359,24 +1374,10 @@ export function createInProcessFetch(generate) {
1359
1374
  throw new Error("toolnexus: createInProcessClient does not support streaming — `generate` returns a " +
1360
1375
  "complete answer. Use the non-streaming run, or supply a `fetch`/transport that streams.");
1361
1376
  }
1362
- const answer = await generate({
1363
- messages: body.messages ?? [],
1364
- tools: body.tools,
1365
- model: body.model,
1366
- body,
1367
- });
1377
+ const answer = generate ? await generate(request) : await generateStream(request, () => { });
1368
1378
  const message = { role: "assistant" };
1369
1379
  if (answer.toolCalls && answer.toolCalls.length > 0) {
1370
- message.tool_calls = answer.toolCalls.map((c, i) => ({
1371
- id: c.id ?? `call_${i}`,
1372
- type: "function",
1373
- function: {
1374
- name: c.name,
1375
- // A string is already encoded; anything else is encoded here so a model
1376
- // author never has to think about the wire.
1377
- arguments: typeof c.arguments === "string" ? c.arguments : JSON.stringify(c.arguments ?? {}),
1378
- },
1379
- }));
1380
+ message.tool_calls = wireToolCalls(answer.toolCalls, false);
1380
1381
  }
1381
1382
  else {
1382
1383
  message.content = answer.content ?? "";
@@ -1395,6 +1396,80 @@ export function createInProcessFetch(generate) {
1395
1396
  };
1396
1397
  return inProcessFetch;
1397
1398
  }
1399
+ function wireToolCalls(calls, indexed) {
1400
+ return calls.map((c, i) => ({
1401
+ ...(indexed ? { index: i } : {}),
1402
+ id: c.id ?? `call_${i}`,
1403
+ type: "function",
1404
+ function: {
1405
+ name: c.name,
1406
+ // A string is already encoded; anything else is encoded here so a model
1407
+ // author never has to think about the wire.
1408
+ arguments: typeof c.arguments === "string" ? c.arguments : JSON.stringify(c.arguments ?? {}),
1409
+ },
1410
+ }));
1411
+ }
1412
+ /** Runs a streaming generate and answers with an OpenAI SSE body written AS EVENTS ARRIVE:
1413
+ * text → a content delta; any other event → an in-band side chunk for the consumer; then
1414
+ * (tool calls as one delta | the content as one delta when no text was emitted), the
1415
+ * finish_reason chunk, usage, and [DONE]. */
1416
+ function inProcessStream(generateStream, request) {
1417
+ const enc = new TextEncoder();
1418
+ let open = true;
1419
+ const sse = new ReadableStream({
1420
+ start(controller) {
1421
+ const chunk = (v) => {
1422
+ if (open)
1423
+ controller.enqueue(enc.encode(`data: ${JSON.stringify(v)}\n\n`));
1424
+ };
1425
+ let sawText = false;
1426
+ void (async () => {
1427
+ try {
1428
+ const answer = await generateStream(request, (ev) => {
1429
+ if (!ev || typeof ev !== "object")
1430
+ return;
1431
+ if (ev.type === "text") {
1432
+ if (ev.delta) {
1433
+ sawText = true;
1434
+ chunk({ choices: [{ index: 0, delta: { content: ev.delta } }] });
1435
+ }
1436
+ return;
1437
+ }
1438
+ chunk({ [IN_PROCESS_SIDE_KEY]: ev });
1439
+ });
1440
+ let finish = "stop";
1441
+ if (answer.toolCalls && answer.toolCalls.length > 0) {
1442
+ chunk({ choices: [{ index: 0, delta: { tool_calls: wireToolCalls(answer.toolCalls, true) } }] });
1443
+ finish = "tool_calls";
1444
+ }
1445
+ else if (!sawText && answer.content) {
1446
+ chunk({ choices: [{ index: 0, delta: { content: answer.content } }] });
1447
+ }
1448
+ chunk({ choices: [{ index: 0, delta: {}, finish_reason: finish }] });
1449
+ const u = answer.usage;
1450
+ if (u) {
1451
+ const p = u.promptTokens ?? 0, c = u.completionTokens ?? 0;
1452
+ chunk({ choices: [], usage: { prompt_tokens: p, completion_tokens: c, total_tokens: u.totalTokens || p + c } });
1453
+ }
1454
+ if (open)
1455
+ controller.enqueue(enc.encode("data: [DONE]\n\n"));
1456
+ open = false;
1457
+ controller.close();
1458
+ }
1459
+ catch (e) {
1460
+ if (open) {
1461
+ open = false;
1462
+ controller.error(e);
1463
+ }
1464
+ }
1465
+ })();
1466
+ },
1467
+ cancel() {
1468
+ open = false;
1469
+ },
1470
+ });
1471
+ return new Response(sse, { status: 200, headers: { "content-type": "text/event-stream" } });
1472
+ }
1398
1473
  /**
1399
1474
  * A client backed by a model running IN THIS PROCESS — no server, no socket, and no
1400
1475
  * HTTP types to construct.
@@ -1405,11 +1480,15 @@ export function createInProcessFetch(generate) {
1405
1480
  * The `fetch`-shaped adapter itself is {@link createInProcessFetch} — this function
1406
1481
  * is just that adapter wired into a `Client`.
1407
1482
  *
1408
- * Streaming is refused rather than faked — see {@link createInProcessFetch}.
1483
+ * Streaming is refused rather than faked for a plain `generate`; with `generateStream`
1484
+ * it streams for real — see {@link createInProcessFetch}.
1409
1485
  */
1410
1486
  export function createInProcessClient(opts) {
1411
- const { generate, ...rest } = opts;
1412
- const inProcessFetch = createInProcessFetch(generate);
1487
+ const { generate, generateStream, ...rest } = opts;
1488
+ if (typeof generate !== "function" && typeof generateStream !== "function") {
1489
+ throw new Error("toolnexus: createInProcessClient requires a `generate` (or `generateStream`) function");
1490
+ }
1491
+ const inProcessFetch = createInProcessFetch(generate, generateStream);
1413
1492
  return new Client({
1414
1493
  // Retries default to ZERO here, unlike a network client. There is no wire, so
1415
1494
  // there is no transient failure to ride out: whatever `generate` throws will
package/dist/index.d.ts CHANGED
@@ -10,6 +10,8 @@ export * from "./a2a.js";
10
10
  export * from "./acp.js";
11
11
  export * from "./serve.js";
12
12
  export * from "./mcpserve.js";
13
+ export * from "./mcphandler.js";
14
+ export * from "./mcpserverauth.js";
13
15
  export * from "./adapters.js";
14
16
  export * from "./translate.js";
15
17
  export * from "./toolkit.js";
package/dist/index.js CHANGED
@@ -10,6 +10,8 @@ export * from "./a2a.js";
10
10
  export * from "./acp.js";
11
11
  export * from "./serve.js";
12
12
  export * from "./mcpserve.js";
13
+ export * from "./mcphandler.js";
14
+ export * from "./mcpserverauth.js";
13
15
  export * from "./adapters.js";
14
16
  export * from "./translate.js";
15
17
  export * from "./toolkit.js";
package/dist/mcp.d.ts CHANGED
@@ -1,6 +1,11 @@
1
1
  import type { ElicitRequest, ElicitResult } from "@modelcontextprotocol/sdk/types.js";
2
- import { type JSONSchema, type Tool, type ToolHints, type McpStatus, type Request, type Answer } from "./types.js";
2
+ import { type JSONSchema, type Tool, type ToolHints, type ToolResult, type McpStatus, type Request, type Answer } from "./types.js";
3
3
  import { type ContentPart } from "./content.js";
4
+ import type { SkillDef } from "./skill.js";
5
+ import { type AuthConfig } from "./mcp2026/source.js";
6
+ import { type TokenStore } from "./mcp2026/auth.js";
7
+ export { Token, MemoryTokenStore, type TokenStore } from "./mcp2026/auth.js";
8
+ export type { AuthConfig } from "./mcp2026/source.js";
4
9
  /** Map an MCP `elicitation/create` request onto a §10 `Request`. */
5
10
  export declare function elicitationToRequest(params: ElicitRequest["params"]): Request;
6
11
  /** Map a resolved §10 `Answer` back onto an MCP `ElicitResult`. ok→accept; declined→decline; else→cancel. */
@@ -18,6 +23,10 @@ export interface LocalServer {
18
23
  * builtins/skills filter (nil/empty ⇒ all; ≥1 true ⇒ allowlist; only-false ⇒ drop-list; unknown
19
24
  * ⇒ ignore+warn). Applied to the listed defs before sanitize/prefix. */
20
25
  tools?: Record<string, boolean>;
26
+ /** §2.8 lazy tool search: expose `<server>_search_tools` + `<server>_call_tool` instead of every tool. */
27
+ toolSearch?: boolean;
28
+ /** §2.8 what this server is for (shown by search_tools). */
29
+ description?: string;
21
30
  }
22
31
  export interface RemoteServer {
23
32
  type?: "remote";
@@ -28,6 +37,11 @@ export interface RemoteServer {
28
37
  timeout?: number;
29
38
  /** §2 Gap 7. Per-server tool allowlist (see LocalServer.tools). */
30
39
  tools?: Record<string, boolean>;
40
+ /** §2.8 lazy tool search (see LocalServer.toolSearch). */
41
+ toolSearch?: boolean;
42
+ description?: string;
43
+ /** §2A OAuth 2.1 client settings. Absent ⇒ a 401 still triggers the flow (CIMD if the AS supports it). */
44
+ auth?: AuthConfig;
31
45
  }
32
46
  export type ServerConfig = LocalServer | RemoteServer;
33
47
  export type McpConfig = Record<string, ServerConfig>;
@@ -46,6 +60,8 @@ export declare function expandEnvHeaders(headers?: Record<string, string>): Reco
46
60
  * takes it there.
47
61
  */
48
62
  export declare function mcpContentToParts(content: unknown): ContentPart[] | undefined;
63
+ /** §2 `tools/call` result → ToolResult (shared by the 2026 path and the frozen fallback). */
64
+ export declare function mapCallToolResult(result: any, server: string): ToolResult;
49
65
  export interface McpSource {
50
66
  tools: Tool[];
51
67
  status: Record<string, McpStatus>;
@@ -56,6 +72,8 @@ export interface McpSource {
56
72
  * source is closed.
57
73
  */
58
74
  resourceTools(): Tool[];
75
+ /** §2.7 skills served by 2026 servers (skills extension); the toolkit adds them to the `skill` tool. */
76
+ skills: SkillDef[];
59
77
  }
60
78
  /** One server's outcome, reported by `loadMcp`'s `onServer` the moment that server finishes. */
61
79
  export interface McpServerResult {
@@ -75,12 +93,21 @@ export declare function mcpHints(a: {
75
93
  destructiveHint?: boolean;
76
94
  openWorldHint?: boolean;
77
95
  } | undefined): ToolHints | undefined;
96
+ /** Options shared by loadMcp / listMcpTools for the 2026 path and OAuth (§2A.1a). */
97
+ export interface McpLoadOptions {
98
+ waitFor?: (request: Request) => Promise<Answer>;
99
+ signal?: AbortSignal;
100
+ /** Where OAuth tokens live (default: in memory, per load). */
101
+ tokenStore?: TokenStore;
102
+ /** How `auto` OAuth mode opens the authorization URL (default: the OS opener). */
103
+ openUrl?: (url: string) => unknown | Promise<unknown>;
104
+ /** Final exposed names to drop; on a tool-search server they are also hidden from search/call. */
105
+ disableTools?: string[];
106
+ }
78
107
  /** Connect to every enabled server, list + convert tools. Failures are isolated.
79
108
  * §2 Gap 3: an optional `signal` bounds each connect/list by the server timeout and aborts the
80
109
  * whole load promptly on parent cancellation. */
81
- export declare function loadMcp(input: string | object, opts?: {
82
- waitFor?: (request: Request) => Promise<Answer>;
83
- signal?: AbortSignal;
110
+ export declare function loadMcp(input: string | object, opts?: McpLoadOptions & {
84
111
  /**
85
112
  * Called once per server the moment it is connected, failed or found disabled (SPEC §4C),
86
113
  * so a host can register a fast server's tools while a slow one is still connecting.
@@ -105,6 +132,4 @@ export interface McpInventory {
105
132
  * §2 Gap 6. Connect to every enabled server, list tool definitions, and DISCONNECT before
106
133
  * returning — no toolkit, no execute wiring, nothing left running. Failure isolation as in loadMcp.
107
134
  */
108
- export declare function listMcpTools(input: string | object, opts?: {
109
- signal?: AbortSignal;
110
- }): Promise<McpInventory>;
135
+ export declare function listMcpTools(input: string | object, opts?: McpLoadOptions): Promise<McpInventory>;
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 };