toolnexus 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/toolkit.d.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  * single uniform list, with provider adapters and a single execute() router.
4
4
  */
5
5
  import { type Tool, type ToolContext, type ToolResult, type McpStatus } from "./types.js";
6
+ import { type SkillDef } from "./skill.js";
6
7
  import { type BuiltinsConfig } from "./builtin.js";
7
8
  import { type Agent } from "./a2a.js";
8
9
  import { type A2AConfig, type OnTask, type ServeHandle } from "./serve.js";
@@ -11,11 +12,26 @@ import type { Client } from "./client.js";
11
12
  export interface ToolkitOptions {
12
13
  mcpConfig?: string | object;
13
14
  skillsDir?: string | string[];
15
+ /** Skills supplied as data, bypassing the filesystem (§3, S1). */
16
+ skills?: SkillDef[];
17
+ /** Lazy provider of data-supplied skills, resolved once at toolkit build (§3, S1). */
18
+ skillProvider?: () => SkillDef[] | Promise<SkillDef[]>;
19
+ /** Per-agent skill allowlist keyed on name; same semantics as the MCP tools filter (§3, S2). */
20
+ skillsFilter?: Record<string, boolean>;
21
+ /** Sibling-file sample cap: 0 ⇒ default 10, n>0 ⇒ cap, -1 ⇒ omit <skill_files> (§3, S5). */
22
+ skillSampleLimit?: number;
14
23
  extraTools?: Tool[];
15
24
  /** Built-in tools (§4A). On by default. false | { disabled:true } | { enabled:false } ⇒ off. */
16
25
  builtins?: BuiltinsConfig;
17
26
  /** Remote A2A agents — each advertised skill becomes a tool (source:"a2a"). */
18
27
  agents?: Agent[];
28
+ /**
29
+ * Host resolver for out-of-band input (§10). When set, connected MCP servers may elicit input
30
+ * from the human mid-`tools/call` and it is bridged onto this `waitFor` (form→kind:"input",
31
+ * URL→kind:"authorization"). Typically the same function passed to the client. Omit ⇒ MCP
32
+ * elicitation is not advertised.
33
+ */
34
+ waitFor?: (request: import("./types.js").Request) => Promise<import("./types.js").Answer>;
19
35
  }
20
36
  export declare class Toolkit {
21
37
  private readonly mcp;
package/dist/toolkit.js CHANGED
@@ -34,8 +34,30 @@ export class Toolkit {
34
34
  }
35
35
  }
36
36
  static async create(opts) {
37
- const mcp = opts.mcpConfig !== undefined ? await loadMcp(opts.mcpConfig) : undefined;
38
- const skill = opts.skillsDir !== undefined ? loadSkills(opts.skillsDir) : undefined;
37
+ const mcp = opts.mcpConfig !== undefined ? await loadMcp(opts.mcpConfig, { waitFor: opts.waitFor }) : undefined;
38
+ // Skills come from directories, in-memory data, and/or a lazy provider (§3,
39
+ // S1). The provider is resolved here (create() is async) and merged with the
40
+ // data list; a provider failure is isolated so other sources still load.
41
+ let skill;
42
+ if (opts.skillsDir !== undefined || opts.skills !== undefined || opts.skillProvider !== undefined) {
43
+ let providerDefs = [];
44
+ if (opts.skillProvider) {
45
+ try {
46
+ providerDefs = await opts.skillProvider();
47
+ }
48
+ catch (e) {
49
+ console.warn(`[toolnexus] skill provider failed: ${e instanceof Error ? e.message : String(e)}`);
50
+ providerDefs = [];
51
+ }
52
+ }
53
+ const dataDefs = [...(opts.skills ?? []), ...providerDefs];
54
+ skill = loadSkills({
55
+ dirs: opts.skillsDir,
56
+ skills: dataDefs.length > 0 ? dataDefs : undefined,
57
+ filter: opts.skillsFilter,
58
+ sampleLimit: opts.skillSampleLimit,
59
+ });
60
+ }
39
61
  // The toggle comes from the `builtins` option, or a top-level `builtins`
40
62
  // key on a parsed config object — same precedence as MCP's isEnabled.
41
63
  let builtinsCfg = opts.builtins;
package/dist/types.d.ts CHANGED
@@ -37,6 +37,12 @@ export interface Answer {
37
37
  id: string;
38
38
  ok: boolean;
39
39
  data?: Record<string, unknown>;
40
+ /**
41
+ * When `ok` is false, why (advisory — the loop rule branches only on `ok`). Distinguishes an
42
+ * explicit refusal from a dismissal/timeout; the MCP elicitation bridge maps `declined`→`decline`
43
+ * and everything else→`cancel`. (R1)
44
+ */
45
+ reason?: "declined" | "cancelled" | "expired";
40
46
  }
41
47
  /** Producer helper: return a suspension. A ToolResult with `metadata.pending` = a Request. */
42
48
  export declare function pending(request: Omit<Request, "id"> & {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolnexus",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Dynamic MCP servers + agent skills as uniform tools for any LLM (opencode-style).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/builtin.ts CHANGED
@@ -12,6 +12,7 @@ import fs from "node:fs/promises"
12
12
  import { existsSync, readdirSync } from "node:fs"
13
13
  import path from "node:path"
14
14
  import type { JSONSchema, Tool, ToolContext, ToolResult } from "./types.js"
15
+ import { pending } from "./types.js"
15
16
 
16
17
  /** Config for the single global builtin toggle (mirrors MCP isEnabled precedence). */
17
18
  export type BuiltinsConfig =
@@ -412,10 +413,27 @@ function webfetchTool(): Tool {
412
413
  )
413
414
  }
414
415
 
416
+ /**
417
+ * Render the questions into a human-readable `Request.prompt` (§10). Byte-identical across ports:
418
+ * each question's text in order, `" (options: a, b, c)"` appended when it has non-empty options,
419
+ * joined by "\n" (no trailing newline). `header` is not rendered — it survives in `data.questions`.
420
+ */
421
+ function renderQuestionPrompt(questions: unknown[]): string {
422
+ return questions
423
+ .map((q) => {
424
+ const item = q as { question?: unknown; options?: unknown }
425
+ let line = typeof item?.question === "string" ? item.question : ""
426
+ const opts = Array.isArray(item?.options) ? item.options : []
427
+ if (opts.length > 0) line += ` (options: ${opts.join(", ")})`
428
+ return line
429
+ })
430
+ .join("\n")
431
+ }
432
+
415
433
  function questionTool(): Tool {
416
434
  return builtin(
417
435
  "question",
418
- "Ask the host one or more questions. Returns the questions as structured output for the host to answer.",
436
+ "Ask the host one or more questions. Suspends via a kind:\"question\" Request (§10); the host's waitFor resolves it and the answer is returned to the model.",
419
437
  {
420
438
  type: "object",
421
439
  properties: {
@@ -437,9 +455,13 @@ function questionTool(): Tool {
437
455
  required: ["questions"],
438
456
  additionalProperties: false,
439
457
  },
440
- async (args) => {
458
+ async (args, ctx) => {
441
459
  const questions = Array.isArray(args.questions) ? args.questions : []
442
- return ok(JSON.stringify(questions), { questions })
460
+ // Re-executed after the host's waitFor resolved (§10 loop rule): the resolution IS the
461
+ // answer, as with kind:"input" — forward it verbatim to the model.
462
+ if (ctx?.answer) return ok(JSON.stringify(ctx.answer.data ?? {}))
463
+ // First call: suspend. A question is just a §10 Request with kind:"question".
464
+ return pending({ kind: "question", prompt: renderQuestionPrompt(questions), data: { questions } })
443
465
  },
444
466
  )
445
467
  }
package/src/client.ts CHANGED
@@ -44,7 +44,7 @@ export interface ClientOptions {
44
44
  */
45
45
  export type MetricEvent =
46
46
  | { event: "llm"; model: string; status: "ok" | "error"; ms: number; promptTokens: number; completionTokens: number }
47
- | { event: "tool"; tool: string; source: string; isError: boolean; ms: number }
47
+ | { event: "tool"; tool: string; source: string; isError: boolean; ms: number; pending?: boolean }
48
48
  | { event: "run"; model: string; turns: number; toolCalls: number; totalTokens: number; ms: number; error?: string }
49
49
 
50
50
  /**
@@ -227,7 +227,7 @@ class MetricsRegistry {
227
227
  }
228
228
  observe(this.llmDuration, labelStr([["model", ev.model]]), ev.ms / 1000)
229
229
  } else if (ev.event === "tool") {
230
- inc(this.toolCalls, labelStr([["tool", ev.tool], ["source", ev.source], ["is_error", String(ev.isError)]]))
230
+ inc(this.toolCalls, labelStr([["tool", ev.tool], ["source", ev.source], ["is_error", String(ev.isError)], ["pending", String(ev.pending ?? false)]]))
231
231
  observe(this.toolDuration, labelStr([["tool", ev.tool]]), ev.ms / 1000)
232
232
  } else if (ev.event === "run") {
233
233
  if (ev.error) inc(this.runErrors, labelStr([["model", ev.model]]))
@@ -402,21 +402,31 @@ export class Client {
402
402
  if (h?.beforeTool) {
403
403
  const ov = await h.beforeTool({ name, args: a, id, turn })
404
404
  if (ov?.result) {
405
- // short-circuit (deny/cache/dry-run) — still a tool call for metrics purposes
406
- this.emit({ event: "tool", tool: name, source, isError: ov.result.isError, ms: Date.now() - t0 })
405
+ // short-circuit (deny/cache/dry-run, or a guard-raised suspension — path B); still a tool
406
+ // call for metrics, but a suspension is classified pending, never an error.
407
+ this.emitTool(name, source, ov.result, t0)
407
408
  return { args: a, result: ov.result }
408
409
  }
409
410
  if (ov?.args) a = ov.args
410
411
  }
411
412
  let result = await toolkit.execute(name, a)
412
- if (h?.afterTool) {
413
+ // A suspension (§10) is not a real result: skip afterTool's failure path on it — the resolved
414
+ // result (post-waitFor) still flows through afterTool in resolvePending — and never count it as
415
+ // a tool error.
416
+ if (h?.afterTool && !pendingOf(result)) {
413
417
  const ov = await h.afterTool({ name, args: a, result, id, turn })
414
418
  if (ov?.result) result = ov.result
415
419
  }
416
- this.emit({ event: "tool", tool: name, source, isError: result.isError, ms: Date.now() - t0 })
420
+ this.emitTool(name, source, result, t0)
417
421
  return { args: a, result }
418
422
  }
419
423
 
424
+ /** Emit the `tool` metric, classifying a §10 suspension as `pending` (isError:false), not a failure. */
425
+ private emitTool(name: string, source: string, result: ToolResult, t0: number) {
426
+ const req = pendingOf(result)
427
+ this.emit({ event: "tool", tool: name, source, isError: req ? false : result.isError, ms: Date.now() - t0, ...(req ? { pending: true } : {}) })
428
+ }
429
+
420
430
  /** One non-streaming LLM call, with an `llm` metric event (ok/error + per-call tokens + ms). */
421
431
  private async llmCallJson(url: string, init: RequestInit, signal: AbortSignal, style: ClientStyle): Promise<any> {
422
432
  const t0 = Date.now()
@@ -469,22 +479,27 @@ export class Client {
469
479
  const calls = msg.tool_calls ?? []
470
480
  if (calls.length === 0) return this.endRun(runStart, msg.content ?? "", messages, toolCalls, turns, usage)
471
481
  // execute all tool calls in this turn concurrently (true parallel tool calling)
472
- let halted: Request | undefined
473
- const results = await Promise.all(
482
+ const settled = await Promise.all(
474
483
  calls.map(async (call: any) => {
475
484
  let { args, result } = await this.runTool(toolkit, call.function.name, safeJson(call.function.arguments), call.id, turn)
485
+ let halted: Request | undefined
476
486
  const req = pendingOf(result)
477
487
  if (req) {
478
488
  const r = await this.resolvePending(toolkit, call.function.name, args, req, call.id, turn)
479
489
  result = r.result
480
- if (r.halted) halted = r.halted
490
+ halted = r.halted
481
491
  }
482
- toolCalls.push({ name: call.function.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
483
- return { role: "tool", tool_call_id: call.id, content: result.output }
492
+ return { call, args, result, halted }
484
493
  }),
485
494
  )
486
- messages.push(...results)
487
- if (halted) return this.pendingRun(runStart, halted, messages, toolCalls, turns, usage)
495
+ // Record in tool-call order; on the FIRST durable halt, surface it and stop — deterministic,
496
+ // and the later suspensions' placeholder results never enter the transcript (they re-suspend
497
+ // on resume). Mirrors the streaming path.
498
+ for (const s of settled) {
499
+ toolCalls.push({ name: s.call.function.name, args: s.args, output: s.result.output, isError: s.result.isError, metadata: s.result.metadata })
500
+ messages.push({ role: "tool", tool_call_id: s.call.id, content: s.result.output })
501
+ if (s.halted) return this.pendingRun(runStart, s.halted, messages, toolCalls, turns, usage)
502
+ }
488
503
  }
489
504
  return this.endRun(runStart, lastText(messages), messages, toolCalls, turns, usage)
490
505
  } catch (e) {
@@ -520,7 +535,7 @@ export class Client {
520
535
  const ov = await this.opts.hooks.afterTool({ name, args, result, id, turn })
521
536
  if (ov?.result) result = ov.result
522
537
  }
523
- this.emit({ event: "tool", tool: name, source: toolkit.get(name)?.source ?? "custom", isError: result.isError, ms: Date.now() - t0 })
538
+ this.emitTool(name, toolkit.get(name)?.source ?? "custom", result, t0)
524
539
  if (pendingOf(result)) result = { output: `unresolved: ${request.prompt}`, isError: true } // never loop forever
525
540
  return { result }
526
541
  }
@@ -566,21 +581,30 @@ export class Client {
566
581
  return this.endRun(runStart, text, messages, toolCalls, turns, usage)
567
582
  }
568
583
  // execute all tool_use blocks in this turn concurrently (true parallel tool calling)
569
- let halted: Request | undefined
570
- const results = await Promise.all(
584
+ const settled = await Promise.all(
571
585
  uses.map(async (use: any) => {
572
586
  let { args, result } = await this.runTool(toolkit, use.name, use.input ?? {}, use.id, turn)
587
+ let halted: Request | undefined
573
588
  const req = pendingOf(result)
574
589
  if (req) {
575
590
  const r = await this.resolvePending(toolkit, use.name, args, req, use.id, turn)
576
591
  result = r.result
577
- if (r.halted) halted = r.halted
592
+ halted = r.halted
578
593
  }
579
- toolCalls.push({ name: use.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
580
- return { type: "tool_result", tool_use_id: use.id, content: result.output, is_error: result.isError }
594
+ return { use, args, result, halted }
581
595
  }),
582
596
  )
583
- messages.push({ role: "user", content: results })
597
+ // Record in tool-call order; on the FIRST durable halt, include that tool_result block, stop
598
+ // building the content, and surface it — deterministic (G3), later suspensions re-suspend on
599
+ // resume. Mirrors the OpenAI path and the streaming loops.
600
+ const content: any[] = []
601
+ let halted: Request | undefined
602
+ for (const s of settled) {
603
+ toolCalls.push({ name: s.use.name, args: s.args, output: s.result.output, isError: s.result.isError, metadata: s.result.metadata })
604
+ content.push({ type: "tool_result", tool_use_id: s.use.id, content: s.result.output, is_error: s.result.isError })
605
+ if (s.halted) { halted = s.halted; break }
606
+ }
607
+ messages.push({ role: "user", content })
584
608
  if (halted) return this.pendingRun(runStart, halted, messages, toolCalls, turns, usage)
585
609
  }
586
610
  return this.endRun(runStart, "", messages, toolCalls, turns, usage)
package/src/mcp.ts CHANGED
@@ -8,8 +8,36 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"
8
8
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
9
9
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
10
10
  import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
11
- import type { Tool as McpToolDef } from "@modelcontextprotocol/sdk/types.js"
12
- import { type JSONSchema, type Tool, type ToolContext, type ToolResult, type McpStatus, sanitize } from "./types.js"
11
+ import type { Tool as McpToolDef, ElicitRequest, ElicitResult } from "@modelcontextprotocol/sdk/types.js"
12
+ import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"
13
+ import { type JSONSchema, type Tool, type ToolContext, type ToolResult, type McpStatus, type Request, type Answer, sanitize } from "./types.js"
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // MCP elicitation bridge (§10). A server can ask US for input mid-`tools/call`
17
+ // (a reverse-request). We map it onto the one `waitFor`: form mode → kind:"input",
18
+ // URL mode → kind:"authorization". The two mappings are pure + byte-parity-tested.
19
+ // ---------------------------------------------------------------------------
20
+
21
+ let _elcSeq = 0
22
+ /** Map an MCP `elicitation/create` request onto a §10 `Request`. */
23
+ export function elicitationToRequest(params: ElicitRequest["params"]): Request {
24
+ const p = params as { message?: string; requestedSchema?: unknown; mode?: string; url?: string }
25
+ const isUrl = p.mode === "url"
26
+ const req: Request = {
27
+ id: `elc-${Date.now().toString(36)}-${++_elcSeq}`,
28
+ kind: isUrl ? "authorization" : "input",
29
+ prompt: String(p.message ?? ""),
30
+ }
31
+ if (isUrl && p.url) req.url = p.url
32
+ if (!isUrl && p.requestedSchema) req.data = { schema: p.requestedSchema }
33
+ return req
34
+ }
35
+
36
+ /** Map a resolved §10 `Answer` back onto an MCP `ElicitResult`. ok→accept; declined→decline; else→cancel. */
37
+ export function answerToElicitResult(answer: Answer): ElicitResult {
38
+ if (answer.ok) return { action: "accept", content: (answer.data ?? {}) as ElicitResult["content"] }
39
+ return { action: answer.reason === "declined" ? "decline" : "cancel" }
40
+ }
13
41
 
14
42
  const DEFAULT_TIMEOUT = 30_000
15
43
  const MAX_LIST_PAGES = 1_000
@@ -155,11 +183,12 @@ export interface McpSource {
155
183
  }
156
184
 
157
185
  /** Connect to every enabled server, list + convert tools. Failures are isolated. */
158
- export async function loadMcp(input: string | object): Promise<McpSource> {
186
+ export async function loadMcp(input: string | object, opts?: { waitFor?: (request: Request) => Promise<Answer> }): Promise<McpSource> {
159
187
  const config = parseMcpConfig(input)
160
188
  const tools: Tool[] = []
161
189
  const status: Record<string, McpStatus> = {}
162
190
  const clients: Client[] = []
191
+ const waitFor = opts?.waitFor
163
192
 
164
193
  await Promise.all(
165
194
  Object.entries(config).map(async ([name, cfg]) => {
@@ -168,7 +197,14 @@ export async function loadMcp(input: string | object): Promise<McpSource> {
168
197
  return
169
198
  }
170
199
  const timeout = cfg.timeout ?? DEFAULT_TIMEOUT
171
- const client = new Client({ name: "toolnexus", version: "0.1.0" }, { capabilities: {} })
200
+ // Advertise elicitation + register the bridge ONLY when a waitFor exists to satisfy it —
201
+ // a waitFor-less host degrades cleanly (the server won't elicit). (§10 elicitation bridge)
202
+ const client = new Client({ name: "toolnexus", version: "0.1.0" }, { capabilities: waitFor ? { elicitation: {} } : {} })
203
+ if (waitFor) {
204
+ client.setRequestHandler(ElicitRequestSchema, async (request) =>
205
+ answerToElicitResult(await waitFor(elicitationToRequest(request.params))),
206
+ )
207
+ }
172
208
  try {
173
209
  if (isRemote(cfg)) {
174
210
  const url = new URL(cfg.url)
package/src/serve.ts CHANGED
@@ -62,7 +62,13 @@ export class FileTaskStore implements TaskStore {
62
62
  }
63
63
  }
64
64
  async save(task: A2ATask): Promise<void> {
65
- fs.writeFileSync(this.file(task.id), JSON.stringify(task))
65
+ // Atomic write: a concurrent get() must never read a half-written file (that
66
+ // would surface as a spurious "Task not found" mid-poll). Write a temp file in
67
+ // the same dir, then rename over the target.
68
+ const target = this.file(task.id)
69
+ const tmp = `${target}.tmp`
70
+ fs.writeFileSync(tmp, JSON.stringify(task))
71
+ fs.renameSync(tmp, target)
66
72
  }
67
73
  }
68
74
 
@@ -331,12 +337,22 @@ async function fulfil(
331
337
  // contextId groups a peer's turns into one conversation — thread it so the
332
338
  // client's ConversationStore remembers across tasks in the same context.
333
339
  result = await runTask(text, contextId)
334
- const artifact: A2AArtifact = {
335
- artifactId: randomUUID(),
336
- parts: [{ kind: "text", text: result.text }],
340
+ if (result.status === "pending" && result.pending) {
341
+ // §10 suspension over A2A: the run halted waiting on out-of-band input. Surface the
342
+ // protocol's `input-required` state carrying the request prompt — never a false `completed`.
343
+ task = {
344
+ id,
345
+ status: { state: "input-required", message: { role: "agent", parts: [{ kind: "text", text: result.pending.prompt }] } },
346
+ }
347
+ state = "input-required"
348
+ } else {
349
+ const artifact: A2AArtifact = {
350
+ artifactId: randomUUID(),
351
+ parts: [{ kind: "text", text: result.text }],
352
+ }
353
+ task = { id, status: { state: "completed" }, artifacts: [artifact] }
354
+ state = "completed"
337
355
  }
338
- task = { id, status: { state: "completed" }, artifacts: [artifact] }
339
- state = "completed"
340
356
  } catch (e) {
341
357
  const detail = e instanceof Error ? e.message : String(e)
342
358
  task = { id, status: { state: "failed", message: { role: "agent", parts: [{ kind: "text", text: detail }] } } }