toolnexus 0.23.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 (65) hide show
  1. package/README.md +139 -1
  2. package/dist/acp.d.ts +37 -6
  3. package/dist/acp.js +239 -52
  4. package/dist/agents/loop.js +4 -1
  5. package/dist/agents/runtime.js +4 -1
  6. package/dist/client.d.ts +55 -9
  7. package/dist/client.js +154 -41
  8. package/dist/index.d.ts +4 -0
  9. package/dist/index.js +4 -0
  10. package/dist/mcp.d.ts +32 -7
  11. package/dist/mcp.js +125 -36
  12. package/dist/mcp2026/auth.d.ts +137 -0
  13. package/dist/mcp2026/auth.js +446 -0
  14. package/dist/mcp2026/client.d.ts +48 -0
  15. package/dist/mcp2026/client.js +160 -0
  16. package/dist/mcp2026/protocol.d.ts +54 -0
  17. package/dist/mcp2026/protocol.js +113 -0
  18. package/dist/mcp2026/skills.d.ts +5 -0
  19. package/dist/mcp2026/skills.js +25 -0
  20. package/dist/mcp2026/source.d.ts +86 -0
  21. package/dist/mcp2026/source.js +372 -0
  22. package/dist/mcp2026/stdio.d.ts +24 -0
  23. package/dist/mcp2026/stdio.js +99 -0
  24. package/dist/mcp2026/toolsearch.d.ts +5 -0
  25. package/dist/mcp2026/toolsearch.js +58 -0
  26. package/dist/mcphandler.d.ts +169 -0
  27. package/dist/mcphandler.js +1145 -0
  28. package/dist/mcpheader.d.ts +5 -0
  29. package/dist/mcpheader.js +18 -0
  30. package/dist/mcpserve.d.ts +24 -15
  31. package/dist/mcpserve.js +105 -2
  32. package/dist/mcpserverauth.d.ts +48 -0
  33. package/dist/mcpserverauth.js +203 -0
  34. package/dist/media.d.ts +126 -0
  35. package/dist/media.js +540 -0
  36. package/dist/skill.d.ts +21 -0
  37. package/dist/skill.js +44 -0
  38. package/dist/toolconcurrency.d.ts +16 -0
  39. package/dist/toolconcurrency.js +59 -0
  40. package/dist/toolkit.d.ts +23 -1
  41. package/dist/toolkit.js +26 -4
  42. package/dist/types.d.ts +26 -1
  43. package/package.json +5 -3
  44. package/src/acp.ts +258 -55
  45. package/src/agents/loop.ts +4 -1
  46. package/src/agents/runtime.ts +4 -1
  47. package/src/client.ts +196 -48
  48. package/src/index.ts +4 -0
  49. package/src/mcp.ts +162 -43
  50. package/src/mcp2026/auth.ts +473 -0
  51. package/src/mcp2026/client.ts +161 -0
  52. package/src/mcp2026/protocol.ts +135 -0
  53. package/src/mcp2026/skills.ts +22 -0
  54. package/src/mcp2026/source.ts +407 -0
  55. package/src/mcp2026/stdio.ts +102 -0
  56. package/src/mcp2026/toolsearch.ts +56 -0
  57. package/src/mcphandler.ts +1252 -0
  58. package/src/mcpheader.ts +19 -0
  59. package/src/mcpserve.ts +115 -2
  60. package/src/mcpserverauth.ts +216 -0
  61. package/src/media.ts +604 -0
  62. package/src/skill.ts +50 -0
  63. package/src/toolconcurrency.ts +67 -0
  64. package/src/toolkit.ts +35 -5
  65. package/src/types.ts +27 -1
package/src/mcp.ts CHANGED
@@ -12,6 +12,11 @@ import type { Tool as McpToolDef, ElicitRequest, ElicitResult } from "@modelcont
12
12
  import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"
13
13
  import { type JSONSchema, type Tool, type ToolContext, type ToolHints, type ToolResult, type McpStatus, type Request, type Answer, sanitize } from "./types.js"
14
14
  import { type ContentPart, summarizePart } from "./content.js"
15
+ import type { SkillDef } from "./skill.js"
16
+ import { probe, newOAuth, defaultTokenStore, type AuthConfig, type ModernServer, type Load2026Options } from "./mcp2026/source.js"
17
+ import { PendingError, type OAuth, type TokenStore } from "./mcp2026/auth.js"
18
+ export { Token, MemoryTokenStore, type TokenStore } from "./mcp2026/auth.js"
19
+ export type { AuthConfig } from "./mcp2026/source.js"
15
20
 
16
21
  // ---------------------------------------------------------------------------
17
22
  // MCP elicitation bridge (§10). A server can ask US for input mid-`tools/call`
@@ -56,6 +61,10 @@ export interface LocalServer {
56
61
  * builtins/skills filter (nil/empty ⇒ all; ≥1 true ⇒ allowlist; only-false ⇒ drop-list; unknown
57
62
  * ⇒ ignore+warn). Applied to the listed defs before sanitize/prefix. */
58
63
  tools?: Record<string, boolean>
64
+ /** §2.8 lazy tool search: expose `<server>_search_tools` + `<server>_call_tool` instead of every tool. */
65
+ toolSearch?: boolean
66
+ /** §2.8 what this server is for (shown by search_tools). */
67
+ description?: string
59
68
  }
60
69
 
61
70
  export interface RemoteServer {
@@ -67,6 +76,11 @@ export interface RemoteServer {
67
76
  timeout?: number
68
77
  /** §2 Gap 7. Per-server tool allowlist (see LocalServer.tools). */
69
78
  tools?: Record<string, boolean>
79
+ /** §2.8 lazy tool search (see LocalServer.toolSearch). */
80
+ toolSearch?: boolean
81
+ description?: string
82
+ /** §2A OAuth 2.1 client settings. Absent ⇒ a 401 still triggers the flow (CIMD if the AS supports it). */
83
+ auth?: AuthConfig
70
84
  }
71
85
 
72
86
  export type ServerConfig = LocalServer | RemoteServer
@@ -254,6 +268,23 @@ async function paginateTools(client: Client, timeout: number): Promise<McpToolDe
254
268
  throw new Error(`MCP list exceeded ${MAX_LIST_PAGES} pages`)
255
269
  }
256
270
 
271
+ /** §2 `tools/call` result → ToolResult (shared by the 2026 path and the frozen fallback). */
272
+ export function mapCallToolResult(result: any, server: string): ToolResult {
273
+ // Parts are collected BEFORE any branch returns: the isError and structuredContent
274
+ // short-circuits both read `content[]` on the way past, so neither reintroduces the
275
+ // silent drop this mapping exists to remove (§2).
276
+ const parts = mcpContentToParts(result.content)
277
+ const withParts = (r: ToolResult): ToolResult => (parts ? { ...r, parts } : r)
278
+ if (result.isError) {
279
+ return withParts({ output: formatToolErrorContent(result.content), isError: true, metadata: { server } })
280
+ }
281
+ if (result.structuredContent !== undefined && result.structuredContent !== null) {
282
+ return withParts({ output: JSON.stringify(result.structuredContent), isError: false, metadata: { server } })
283
+ }
284
+ const text = joinTextContent(result.content)
285
+ return withParts({ output: text || describeMcpParts(parts), isError: false, metadata: { server } })
286
+ }
287
+
257
288
  function convertTool(server: string, def: McpToolDef, client: Client, timeout: number): Tool {
258
289
  const inputSchema: JSONSchema = {
259
290
  ...(def.inputSchema as object),
@@ -277,19 +308,7 @@ function convertTool(server: string, def: McpToolDef, client: Client, timeout: n
277
308
  // token that matches no in-flight request are dropped by the SDK, so they never reset.
278
309
  { onprogress: () => {}, resetTimeoutOnProgress: true, signal: ctx?.signal, timeout: ctx?.timeout ?? timeout },
279
310
  )
280
- // Parts are collected BEFORE any branch returns: the isError and structuredContent
281
- // short-circuits both read `content[]` on the way past, so neither reintroduces the
282
- // silent drop this mapping exists to remove (§2).
283
- const parts = mcpContentToParts(result.content)
284
- const withParts = (r: ToolResult): ToolResult => (parts ? { ...r, parts } : r)
285
- if (result.isError) {
286
- return withParts({ output: formatToolErrorContent(result.content), isError: true, metadata: { server } })
287
- }
288
- if (result.structuredContent !== undefined && result.structuredContent !== null) {
289
- return withParts({ output: JSON.stringify(result.structuredContent), isError: false, metadata: { server } })
290
- }
291
- const text = joinTextContent(result.content)
292
- return withParts({ output: text || describeMcpParts(parts), isError: false, metadata: { server } })
311
+ return mapCallToolResult(result, server)
293
312
  } catch (e) {
294
313
  return { output: e instanceof Error ? e.message : String(e), isError: true, metadata: { server } }
295
314
  }
@@ -307,6 +326,8 @@ export interface McpSource {
307
326
  * source is closed.
308
327
  */
309
328
  resourceTools(): Tool[]
329
+ /** §2.7 skills served by 2026 servers (skills extension); the toolkit adds them to the `skill` tool. */
330
+ skills: SkillDef[]
310
331
  }
311
332
 
312
333
  /** One server's outcome, reported by `loadMcp`'s `onServer` the moment that server finishes. */
@@ -331,8 +352,19 @@ export function mcpHints(a: { readOnlyHint?: boolean; destructiveHint?: boolean;
331
352
  }
332
353
 
333
354
  interface ServerConn {
334
- client: Client
335
- timeout: number
355
+ listResources(signal?: AbortSignal): Promise<any[]>
356
+ readResource(uri: string, signal?: AbortSignal): Promise<any[]>
357
+ }
358
+
359
+ function sdkConn(client: Client, timeout: number): ServerConn {
360
+ return {
361
+ listResources: async (signal) => ((await client.listResources({}, { timeout, signal })) as any).resources ?? [],
362
+ readResource: async (uri, signal) => ((await client.readResource({ uri }, { timeout, signal })) as any).contents ?? [],
363
+ }
364
+ }
365
+
366
+ function modernConn(s: ModernServer): ServerConn {
367
+ return { listResources: (signal) => s.listResources(signal), readResource: (uri, signal) => s.readResource(uri, signal) }
336
368
  }
337
369
 
338
370
  function resourceTools(servers: Map<string, ServerConn>): Tool[] {
@@ -369,8 +401,7 @@ function resourceTools(servers: Map<string, ServerConn>): Tool[] {
369
401
  for (const name of targets) {
370
402
  const c = conn(name)
371
403
  try {
372
- const res: any = await c.client.listResources({}, { timeout: c.timeout, signal: ctx?.signal })
373
- for (const r of res.resources ?? []) {
404
+ for (const r of await c.listResources(ctx?.signal)) {
374
405
  let line = `${name} ${r.uri}`
375
406
  if (r.name) line += ` — ${r.name}`
376
407
  if (r.mimeType) line += ` (${r.mimeType})`
@@ -411,9 +442,8 @@ function resourceTools(servers: Map<string, ServerConn>): Tool[] {
411
442
  }
412
443
  if (!uri) return { output: "read_mcp_resource: uri is required", isError: true }
413
444
  try {
414
- const res: any = await c.client.readResource({ uri }, { timeout: c.timeout, signal: ctx?.signal })
415
445
  const parts: string[] = []
416
- for (const rc of res.contents ?? []) {
446
+ for (const rc of await c.readResource(uri, ctx?.signal)) {
417
447
  if (typeof rc.text === "string") parts.push(rc.text)
418
448
  else if (typeof rc.blob === "string") parts.push(`[binary ${rc.mimeType ?? ""}, ${rc.blob.length} base64 chars]`)
419
449
  }
@@ -441,15 +471,16 @@ function newMcpClient(waitFor?: (request: Request) => Promise<Answer>): Client {
441
471
  }
442
472
 
443
473
  /** Connect an existing client's transport (remote streamable-HTTP with SSE fallback, or local stdio). */
444
- async function connectTransport(client: Client, cfg: ServerConfig): Promise<void> {
474
+ async function connectTransport(client: Client, cfg: ServerConfig, auth?: FallbackAuth): Promise<void> {
445
475
  if (isRemote(cfg)) {
446
476
  const url = new URL(cfg.url)
447
477
  const expanded = expandEnvHeaders(cfg.headers)
448
478
  const requestInit = expanded ? { headers: expanded } : undefined
479
+ const fetch = auth ? bearerFetch(cfg.url, auth) : undefined
449
480
  try {
450
- await client.connect(new StreamableHTTPClientTransport(url, { requestInit }))
481
+ await client.connect(new StreamableHTTPClientTransport(url, { requestInit, ...(fetch ? { fetch } : {}) }))
451
482
  } catch {
452
- await client.connect(new SSEClientTransport(url, { requestInit }))
483
+ await client.connect(new SSEClientTransport(url, { requestInit, ...(fetch ? { fetch } : {}) }))
453
484
  }
454
485
  } else {
455
486
  const local = cfg as LocalServer
@@ -466,14 +497,90 @@ async function connectTransport(client: Client, cfg: ServerConfig): Promise<void
466
497
  }
467
498
  }
468
499
 
500
+ interface FallbackAuth {
501
+ oauth: OAuth
502
+ waitFor?: (request: Request) => Promise<Answer>
503
+ }
504
+
505
+ /**
506
+ * §2A.1a frozen fallback + OAuth: the SDK transport's fetch attaches the current bearer token
507
+ * (refreshing as needed); a 401/403 challenge runs the same OAuth flow once, then the request is
508
+ * re-sent. A pause is answered through waitFor, else the request fails ("authorization pending").
509
+ */
510
+ function bearerFetch(resource: string, a: FallbackAuth): typeof globalThis.fetch {
511
+ return async (input: any, init?: any) => {
512
+ for (let attempt = 0; ; attempt++) {
513
+ const headers = new Headers(init?.headers)
514
+ const tok = await a.oauth.bearer(resource)
515
+ if (tok) headers.set("Authorization", "Bearer " + tok)
516
+ const res = await globalThis.fetch(input, { ...init, headers })
517
+ const www = res.headers.get("www-authenticate") ?? ""
518
+ if (attempt > 0 || (res.status !== 401 && res.status !== 403) || !www) return res
519
+ try {
520
+ await a.oauth.challenge(resource, res.status, www, tok, async () => ({}))
521
+ } catch (e) {
522
+ if (!(e instanceof PendingError)) throw e
523
+ if (!a.waitFor) throw new Error("authorization pending")
524
+ const ans = await a.waitFor({ ...e.pending })
525
+ if (!ans?.ok) throw new Error(`authorization denied: ${ans?.reason ?? "cancelled"}`)
526
+ const cb = ans.data?.callbackUrl
527
+ if (typeof cb !== "string") throw new Error("authorization answer carries no data.callbackUrl")
528
+ await a.oauth.resume(e.pending.id, cb)
529
+ }
530
+ }
531
+ }
532
+ }
533
+
534
+ /** Options shared by loadMcp / listMcpTools for the 2026 path and OAuth (§2A.1a). */
535
+ export interface McpLoadOptions {
536
+ waitFor?: (request: Request) => Promise<Answer>
537
+ signal?: AbortSignal
538
+ /** Where OAuth tokens live (default: in memory, per load). */
539
+ tokenStore?: TokenStore
540
+ /** How `auto` OAuth mode opens the authorization URL (default: the OS opener). */
541
+ openUrl?: (url: string) => unknown | Promise<unknown>
542
+ /** Final exposed names to drop; on a tool-search server they are also hidden from search/call. */
543
+ disableTools?: string[]
544
+ }
545
+
546
+ type Connected =
547
+ | { kind: "modern"; server: ModernServer }
548
+ | { kind: "sdk"; client: Client; defs: McpToolDef[] }
549
+
550
+ /**
551
+ * Discover first (§2.2): a 2026 server is used directly; an old one (§2.2a) gets the frozen SDK
552
+ * path, with the OAuth bearer attached for remote servers. Throws ⇒ the server failed.
553
+ */
554
+ async function connectServer(name: string, cfg: ServerConfig, timeout: number, opts: McpLoadOptions, store: TokenStore, sdkClient: () => Client, onSdk: (c: Client) => void): Promise<Connected> {
555
+ const o: Load2026Options = { waitFor: opts.waitFor, tokenStore: store, openUrl: opts.openUrl, signal: opts.signal, disableTools: opts.disableTools }
556
+ const remote = isRemote(cfg)
557
+ const oauth = remote ? newOAuth(cfg.auth, o, store) : undefined
558
+ const p = await probe(
559
+ {
560
+ name, timeout, tools: cfg.tools, toolSearch: cfg.toolSearch === true, description: cfg.description,
561
+ remote: remote ? { url: cfg.url, headers: expandEnvHeaders(cfg.headers), oauth: oauth! } : undefined,
562
+ local: remote ? undefined : {
563
+ argv: (cfg as LocalServer).command,
564
+ env: { ...process.env, ...((cfg as LocalServer).environment ?? (cfg as LocalServer).env ?? {}) },
565
+ cwd: (cfg as LocalServer).cwd,
566
+ },
567
+ },
568
+ o,
569
+ )
570
+ if (p.kind === "modern") return p
571
+ const client = sdkClient()
572
+ onSdk(client)
573
+ await raceTimeout(connectTransport(client, cfg, oauth ? { oauth, waitFor: opts.waitFor } : undefined), timeout, opts.signal)
574
+ const defs = await raceTimeout(paginateTools(client, timeout), timeout, opts.signal)
575
+ return { kind: "sdk", client, defs }
576
+ }
577
+
469
578
  /** Connect to every enabled server, list + convert tools. Failures are isolated.
470
579
  * §2 Gap 3: an optional `signal` bounds each connect/list by the server timeout and aborts the
471
580
  * whole load promptly on parent cancellation. */
472
581
  export async function loadMcp(
473
582
  input: string | object,
474
- opts?: {
475
- waitFor?: (request: Request) => Promise<Answer>
476
- signal?: AbortSignal
583
+ opts?: McpLoadOptions & {
477
584
  /**
478
585
  * Called once per server the moment it is connected, failed or found disabled (SPEC §4C),
479
586
  * so a host can register a fast server's tools while a slow one is still connecting.
@@ -486,8 +593,10 @@ export async function loadMcp(
486
593
  const config = parseMcpConfig(input)
487
594
  const tools: Tool[] = []
488
595
  const status: Record<string, McpStatus> = {}
489
- const clients: Client[] = []
596
+ const clients: { close(): Promise<void> }[] = []
490
597
  const servers = new Map<string, ServerConn>()
598
+ const skills: SkillDef[] = []
599
+ const store = defaultTokenStore({ tokenStore: opts?.tokenStore })
491
600
  const waitFor = opts?.waitFor
492
601
  const signal = opts?.signal
493
602
  const onServer = opts?.onServer
@@ -512,22 +621,29 @@ export async function loadMcp(
512
621
  return
513
622
  }
514
623
  const timeout = cfg.timeout ?? DEFAULT_TIMEOUT
515
- // Create the client up front so a timed-out/aborted connect still has a closeable handle
516
- // (its stdio child is killed by close() — never orphaned).
517
- const client = newMcpClient(waitFor)
624
+ // The fallback SDK client is created before its connect so a timed-out/aborted connect
625
+ // still has a closeable handle (its stdio child is killed by close() — never orphaned).
626
+ let client: Client | undefined
518
627
  try {
519
- await raceTimeout(connectTransport(client, cfg), timeout, signal)
520
- const defs = await raceTimeout(paginateTools(client, timeout), timeout, signal)
521
- const kept = applyToolsFilter(name, defs, (cfg as { tools?: Record<string, boolean> }).tools)
522
- const converted = kept.map((def) => convertTool(name, def, client, timeout))
628
+ const c = await connectServer(name, cfg, timeout, opts ?? {}, store, () => newMcpClient(waitFor), (cl) => (client = cl))
629
+ let converted: Tool[]
630
+ if (c.kind === "modern") {
631
+ converted = c.server.tools()
632
+ skills.push(...c.server.skills)
633
+ clients.push(c.server)
634
+ servers.set(name, modernConn(c.server))
635
+ } else {
636
+ const kept = applyToolsFilter(name, c.defs, (cfg as { tools?: Record<string, boolean> }).tools)
637
+ converted = kept.map((def) => convertTool(name, def, c.client, timeout))
638
+ clients.push(c.client)
639
+ servers.set(name, sdkConn(c.client, timeout))
640
+ }
523
641
  tools.push(...converted)
524
- clients.push(client)
525
- servers.set(name, { client, timeout })
526
642
  status[name] = "connected"
527
643
  report({ name, status: "connected", tools: converted })
528
644
  } catch (e) {
529
645
  status[name] = "failed"
530
- await client.close().catch(() => {})
646
+ await (client as Client | undefined)?.close().catch(() => {})
531
647
  // Parent cancellation/deadline aborts the whole load (§2 Gap 3, A1); a per-server timeout
532
648
  // within budget isolates just this server.
533
649
  if (signal?.aborted) throw e
@@ -558,6 +674,7 @@ export async function loadMcp(
558
674
  await Promise.all(clients.map((c) => c.close().catch(() => {})))
559
675
  },
560
676
  resourceTools: () => resourceTools(servers),
677
+ skills,
561
678
  }
562
679
  }
563
680
 
@@ -579,11 +696,12 @@ export interface McpInventory {
579
696
  * §2 Gap 6. Connect to every enabled server, list tool definitions, and DISCONNECT before
580
697
  * returning — no toolkit, no execute wiring, nothing left running. Failure isolation as in loadMcp.
581
698
  */
582
- export async function listMcpTools(input: string | object, opts?: { signal?: AbortSignal }): Promise<McpInventory> {
699
+ export async function listMcpTools(input: string | object, opts?: McpLoadOptions): Promise<McpInventory> {
583
700
  const config = parseMcpConfig(input)
584
701
  const toolsByServer: Record<string, ToolInfo[]> = {}
585
702
  const status: Record<string, McpStatus> = {}
586
703
  const signal = opts?.signal
704
+ const store = defaultTokenStore({ tokenStore: opts?.tokenStore })
587
705
 
588
706
  await Promise.all(
589
707
  Object.entries(config).map(async ([name, cfg]) => {
@@ -592,11 +710,12 @@ export async function listMcpTools(input: string | object, opts?: { signal?: Abo
592
710
  return
593
711
  }
594
712
  const timeout = cfg.timeout ?? DEFAULT_TIMEOUT
595
- // Client created up front so an aborted/timed-out connect is still closed (no orphaned child).
596
- const client = newMcpClient()
713
+ // The fallback client is created before its connect so an aborted/timed-out connect is still closed.
714
+ let client: Client | undefined
597
715
  try {
598
- await raceTimeout(connectTransport(client, cfg), timeout, signal)
599
- const defs = await raceTimeout(paginateTools(client, timeout), timeout, signal)
716
+ const c = await connectServer(name, cfg, timeout, { ...opts, waitFor: opts?.waitFor }, store, () => newMcpClient(), (cl) => (client = cl))
717
+ const defs: any[] = c.kind === "modern" ? c.server.rawDefs() : c.defs
718
+ if (c.kind === "modern") await c.server.close().catch(() => {})
600
719
  toolsByServer[name] = defs.map((d) => ({
601
720
  name: d.name,
602
721
  description: d.description ?? "",
@@ -609,10 +728,10 @@ export async function listMcpTools(input: string | object, opts?: { signal?: Abo
609
728
  status[name] = "connected"
610
729
  } catch (e) {
611
730
  status[name] = "failed"
612
- if (signal?.aborted) { await client.close().catch(() => {}); throw e }
731
+ if (signal?.aborted) { await (client as Client | undefined)?.close().catch(() => {}); throw e }
613
732
  console.warn(`[toolnexus] MCP server "${name}" failed: ${e instanceof Error ? e.message : String(e)}`)
614
733
  } finally {
615
- await client.close().catch(() => {})
734
+ await (client as Client | undefined)?.close().catch(() => {})
616
735
  }
617
736
  }),
618
737
  )