toolnexus 0.6.0 → 0.7.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/builtin.js CHANGED
@@ -11,6 +11,7 @@ import { spawn } from "node:child_process";
11
11
  import fs from "node:fs/promises";
12
12
  import { existsSync, readdirSync } from "node:fs";
13
13
  import path from "node:path";
14
+ import { pending } from "./types.js";
14
15
  /**
15
16
  * Whether the builtin source is on. Default ON. Same precedence as MCP:
16
17
  * `disabled:true` wins, else `enabled:false` disables, otherwise enabled.
@@ -388,8 +389,25 @@ function webfetchTool() {
388
389
  }
389
390
  });
390
391
  }
392
+ /**
393
+ * Render the questions into a human-readable `Request.prompt` (§10). Byte-identical across ports:
394
+ * each question's text in order, `" (options: a, b, c)"` appended when it has non-empty options,
395
+ * joined by "\n" (no trailing newline). `header` is not rendered — it survives in `data.questions`.
396
+ */
397
+ function renderQuestionPrompt(questions) {
398
+ return questions
399
+ .map((q) => {
400
+ const item = q;
401
+ let line = typeof item?.question === "string" ? item.question : "";
402
+ const opts = Array.isArray(item?.options) ? item.options : [];
403
+ if (opts.length > 0)
404
+ line += ` (options: ${opts.join(", ")})`;
405
+ return line;
406
+ })
407
+ .join("\n");
408
+ }
391
409
  function questionTool() {
392
- return builtin("question", "Ask the host one or more questions. Returns the questions as structured output for the host to answer.", {
410
+ return builtin("question", "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.", {
393
411
  type: "object",
394
412
  properties: {
395
413
  questions: {
@@ -409,9 +427,14 @@ function questionTool() {
409
427
  },
410
428
  required: ["questions"],
411
429
  additionalProperties: false,
412
- }, async (args) => {
430
+ }, async (args, ctx) => {
413
431
  const questions = Array.isArray(args.questions) ? args.questions : [];
414
- return ok(JSON.stringify(questions), { questions });
432
+ // Re-executed after the host's waitFor resolved (§10 loop rule): the resolution IS the
433
+ // answer, as with kind:"input" — forward it verbatim to the model.
434
+ if (ctx?.answer)
435
+ return ok(JSON.stringify(ctx.answer.data ?? {}));
436
+ // First call: suspend. A question is just a §10 Request with kind:"question".
437
+ return pending({ kind: "question", prompt: renderQuestionPrompt(questions), data: { questions } });
415
438
  });
416
439
  }
417
440
  function todowriteTool() {
package/dist/client.d.ts CHANGED
@@ -51,6 +51,7 @@ export type MetricEvent = {
51
51
  source: string;
52
52
  isError: boolean;
53
53
  ms: number;
54
+ pending?: boolean;
54
55
  } | {
55
56
  event: "run";
56
57
  model: string;
@@ -237,6 +238,8 @@ export declare class Client {
237
238
  private llmFetch;
238
239
  /** Run one tool through beforeTool/afterTool hooks (mutate args, short-circuit, transform result). */
239
240
  private runTool;
241
+ /** Emit the `tool` metric, classifying a §10 suspension as `pending` (isError:false), not a failure. */
242
+ private emitTool;
240
243
  /** One non-streaming LLM call, with an `llm` metric event (ok/error + per-call tokens + ms). */
241
244
  private llmCallJson;
242
245
  private runOpenAI;
package/dist/client.js CHANGED
@@ -98,7 +98,7 @@ class MetricsRegistry {
98
98
  observe(this.llmDuration, labelStr([["model", ev.model]]), ev.ms / 1000);
99
99
  }
100
100
  else if (ev.event === "tool") {
101
- inc(this.toolCalls, labelStr([["tool", ev.tool], ["source", ev.source], ["is_error", String(ev.isError)]]));
101
+ inc(this.toolCalls, labelStr([["tool", ev.tool], ["source", ev.source], ["is_error", String(ev.isError)], ["pending", String(ev.pending ?? false)]]));
102
102
  observe(this.toolDuration, labelStr([["tool", ev.tool]]), ev.ms / 1000);
103
103
  }
104
104
  else if (ev.event === "run") {
@@ -270,22 +270,31 @@ export class Client {
270
270
  if (h?.beforeTool) {
271
271
  const ov = await h.beforeTool({ name, args: a, id, turn });
272
272
  if (ov?.result) {
273
- // short-circuit (deny/cache/dry-run) — still a tool call for metrics purposes
274
- this.emit({ event: "tool", tool: name, source, isError: ov.result.isError, ms: Date.now() - t0 });
273
+ // short-circuit (deny/cache/dry-run, or a guard-raised suspension — path B); still a tool
274
+ // call for metrics, but a suspension is classified pending, never an error.
275
+ this.emitTool(name, source, ov.result, t0);
275
276
  return { args: a, result: ov.result };
276
277
  }
277
278
  if (ov?.args)
278
279
  a = ov.args;
279
280
  }
280
281
  let result = await toolkit.execute(name, a);
281
- if (h?.afterTool) {
282
+ // A suspension (§10) is not a real result: skip afterTool's failure path on it — the resolved
283
+ // result (post-waitFor) still flows through afterTool in resolvePending — and never count it as
284
+ // a tool error.
285
+ if (h?.afterTool && !pendingOf(result)) {
282
286
  const ov = await h.afterTool({ name, args: a, result, id, turn });
283
287
  if (ov?.result)
284
288
  result = ov.result;
285
289
  }
286
- this.emit({ event: "tool", tool: name, source, isError: result.isError, ms: Date.now() - t0 });
290
+ this.emitTool(name, source, result, t0);
287
291
  return { args: a, result };
288
292
  }
293
+ /** Emit the `tool` metric, classifying a §10 suspension as `pending` (isError:false), not a failure. */
294
+ emitTool(name, source, result, t0) {
295
+ const req = pendingOf(result);
296
+ this.emit({ event: "tool", tool: name, source, isError: req ? false : result.isError, ms: Date.now() - t0, ...(req ? { pending: true } : {}) });
297
+ }
289
298
  /** One non-streaming LLM call, with an `llm` metric event (ok/error + per-call tokens + ms). */
290
299
  async llmCallJson(url, init, signal, style) {
291
300
  const t0 = Date.now();
@@ -343,22 +352,26 @@ export class Client {
343
352
  if (calls.length === 0)
344
353
  return this.endRun(runStart, msg.content ?? "", messages, toolCalls, turns, usage);
345
354
  // execute all tool calls in this turn concurrently (true parallel tool calling)
346
- let halted;
347
- const results = await Promise.all(calls.map(async (call) => {
355
+ const settled = await Promise.all(calls.map(async (call) => {
348
356
  let { args, result } = await this.runTool(toolkit, call.function.name, safeJson(call.function.arguments), call.id, turn);
357
+ let halted;
349
358
  const req = pendingOf(result);
350
359
  if (req) {
351
360
  const r = await this.resolvePending(toolkit, call.function.name, args, req, call.id, turn);
352
361
  result = r.result;
353
- if (r.halted)
354
- halted = r.halted;
362
+ halted = r.halted;
355
363
  }
356
- toolCalls.push({ name: call.function.name, args, output: result.output, isError: result.isError, metadata: result.metadata });
357
- return { role: "tool", tool_call_id: call.id, content: result.output };
364
+ return { call, args, result, halted };
358
365
  }));
359
- messages.push(...results);
360
- if (halted)
361
- return this.pendingRun(runStart, halted, messages, toolCalls, turns, usage);
366
+ // Record in tool-call order; on the FIRST durable halt, surface it and stop — deterministic,
367
+ // and the later suspensions' placeholder results never enter the transcript (they re-suspend
368
+ // on resume). Mirrors the streaming path.
369
+ for (const s of settled) {
370
+ toolCalls.push({ name: s.call.function.name, args: s.args, output: s.result.output, isError: s.result.isError, metadata: s.result.metadata });
371
+ messages.push({ role: "tool", tool_call_id: s.call.id, content: s.result.output });
372
+ if (s.halted)
373
+ return this.pendingRun(runStart, s.halted, messages, toolCalls, turns, usage);
374
+ }
362
375
  }
363
376
  return this.endRun(runStart, lastText(messages), messages, toolCalls, turns, usage);
364
377
  }
@@ -393,7 +406,7 @@ export class Client {
393
406
  if (ov?.result)
394
407
  result = ov.result;
395
408
  }
396
- this.emit({ event: "tool", tool: name, source: toolkit.get(name)?.source ?? "custom", isError: result.isError, ms: Date.now() - t0 });
409
+ this.emitTool(name, toolkit.get(name)?.source ?? "custom", result, t0);
397
410
  if (pendingOf(result))
398
411
  result = { output: `unresolved: ${request.prompt}`, isError: true }; // never loop forever
399
412
  return { result };
@@ -441,20 +454,31 @@ export class Client {
441
454
  return this.endRun(runStart, text, messages, toolCalls, turns, usage);
442
455
  }
443
456
  // execute all tool_use blocks in this turn concurrently (true parallel tool calling)
444
- let halted;
445
- const results = await Promise.all(uses.map(async (use) => {
457
+ const settled = await Promise.all(uses.map(async (use) => {
446
458
  let { args, result } = await this.runTool(toolkit, use.name, use.input ?? {}, use.id, turn);
459
+ let halted;
447
460
  const req = pendingOf(result);
448
461
  if (req) {
449
462
  const r = await this.resolvePending(toolkit, use.name, args, req, use.id, turn);
450
463
  result = r.result;
451
- if (r.halted)
452
- halted = r.halted;
464
+ halted = r.halted;
453
465
  }
454
- toolCalls.push({ name: use.name, args, output: result.output, isError: result.isError, metadata: result.metadata });
455
- return { type: "tool_result", tool_use_id: use.id, content: result.output, is_error: result.isError };
466
+ return { use, args, result, halted };
456
467
  }));
457
- messages.push({ role: "user", content: results });
468
+ // Record in tool-call order; on the FIRST durable halt, include that tool_result block, stop
469
+ // building the content, and surface it — deterministic (G3), later suspensions re-suspend on
470
+ // resume. Mirrors the OpenAI path and the streaming loops.
471
+ const content = [];
472
+ let halted;
473
+ for (const s of settled) {
474
+ toolCalls.push({ name: s.use.name, args: s.args, output: s.result.output, isError: s.result.isError, metadata: s.result.metadata });
475
+ content.push({ type: "tool_result", tool_use_id: s.use.id, content: s.result.output, is_error: s.result.isError });
476
+ if (s.halted) {
477
+ halted = s.halted;
478
+ break;
479
+ }
480
+ }
481
+ messages.push({ role: "user", content });
458
482
  if (halted)
459
483
  return this.pendingRun(runStart, halted, messages, toolCalls, turns, usage);
460
484
  }
package/dist/mcp.d.ts CHANGED
@@ -1,4 +1,9 @@
1
- import { type Tool, type McpStatus } from "./types.js";
1
+ import type { ElicitRequest, ElicitResult } from "@modelcontextprotocol/sdk/types.js";
2
+ import { type Tool, type McpStatus, type Request, type Answer } from "./types.js";
3
+ /** Map an MCP `elicitation/create` request onto a §10 `Request`. */
4
+ export declare function elicitationToRequest(params: ElicitRequest["params"]): Request;
5
+ /** Map a resolved §10 `Answer` back onto an MCP `ElicitResult`. ok→accept; declined→decline; else→cancel. */
6
+ export declare function answerToElicitResult(answer: Answer): ElicitResult;
2
7
  export interface LocalServer {
3
8
  type?: "local";
4
9
  command: string[];
@@ -32,4 +37,6 @@ export interface McpSource {
32
37
  close(): Promise<void>;
33
38
  }
34
39
  /** Connect to every enabled server, list + convert tools. Failures are isolated. */
35
- export declare function loadMcp(input: string | object): Promise<McpSource>;
40
+ export declare function loadMcp(input: string | object, opts?: {
41
+ waitFor?: (request: Request) => Promise<Answer>;
42
+ }): Promise<McpSource>;
package/dist/mcp.js CHANGED
@@ -8,7 +8,35 @@ 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 { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
11
12
  import { sanitize } from "./types.js";
13
+ // ---------------------------------------------------------------------------
14
+ // MCP elicitation bridge (§10). A server can ask US for input mid-`tools/call`
15
+ // (a reverse-request). We map it onto the one `waitFor`: form mode → kind:"input",
16
+ // URL mode → kind:"authorization". The two mappings are pure + byte-parity-tested.
17
+ // ---------------------------------------------------------------------------
18
+ let _elcSeq = 0;
19
+ /** Map an MCP `elicitation/create` request onto a §10 `Request`. */
20
+ export function elicitationToRequest(params) {
21
+ const p = params;
22
+ const isUrl = p.mode === "url";
23
+ const req = {
24
+ id: `elc-${Date.now().toString(36)}-${++_elcSeq}`,
25
+ kind: isUrl ? "authorization" : "input",
26
+ prompt: String(p.message ?? ""),
27
+ };
28
+ if (isUrl && p.url)
29
+ req.url = p.url;
30
+ if (!isUrl && p.requestedSchema)
31
+ req.data = { schema: p.requestedSchema };
32
+ return req;
33
+ }
34
+ /** Map a resolved §10 `Answer` back onto an MCP `ElicitResult`. ok→accept; declined→decline; else→cancel. */
35
+ export function answerToElicitResult(answer) {
36
+ if (answer.ok)
37
+ return { action: "accept", content: (answer.data ?? {}) };
38
+ return { action: answer.reason === "declined" ? "decline" : "cancel" };
39
+ }
12
40
  const DEFAULT_TIMEOUT = 30_000;
13
41
  const MAX_LIST_PAGES = 1_000;
14
42
  function isRemote(cfg) {
@@ -117,18 +145,24 @@ function convertTool(server, def, client, timeout) {
117
145
  };
118
146
  }
119
147
  /** Connect to every enabled server, list + convert tools. Failures are isolated. */
120
- export async function loadMcp(input) {
148
+ export async function loadMcp(input, opts) {
121
149
  const config = parseMcpConfig(input);
122
150
  const tools = [];
123
151
  const status = {};
124
152
  const clients = [];
153
+ const waitFor = opts?.waitFor;
125
154
  await Promise.all(Object.entries(config).map(async ([name, cfg]) => {
126
155
  if (!isEnabled(cfg)) {
127
156
  status[name] = "disabled";
128
157
  return;
129
158
  }
130
159
  const timeout = cfg.timeout ?? DEFAULT_TIMEOUT;
131
- const client = new Client({ name: "toolnexus", version: "0.1.0" }, { capabilities: {} });
160
+ // Advertise elicitation + register the bridge ONLY when a waitFor exists to satisfy it —
161
+ // a waitFor-less host degrades cleanly (the server won't elicit). (§10 elicitation bridge)
162
+ const client = new Client({ name: "toolnexus", version: "0.1.0" }, { capabilities: waitFor ? { elicitation: {} } : {} });
163
+ if (waitFor) {
164
+ client.setRequestHandler(ElicitRequestSchema, async (request) => answerToElicitResult(await waitFor(elicitationToRequest(request.params))));
165
+ }
132
166
  try {
133
167
  if (isRemote(cfg)) {
134
168
  const url = new URL(cfg.url);
package/dist/serve.js CHANGED
@@ -239,12 +239,23 @@ async function fulfil(id, text, store, runTask, onTask, contextId) {
239
239
  // contextId groups a peer's turns into one conversation — thread it so the
240
240
  // client's ConversationStore remembers across tasks in the same context.
241
241
  result = await runTask(text, contextId);
242
- const artifact = {
243
- artifactId: randomUUID(),
244
- parts: [{ kind: "text", text: result.text }],
245
- };
246
- task = { id, status: { state: "completed" }, artifacts: [artifact] };
247
- state = "completed";
242
+ if (result.status === "pending" && result.pending) {
243
+ // §10 suspension over A2A: the run halted waiting on out-of-band input. Surface the
244
+ // protocol's `input-required` state carrying the request prompt — never a false `completed`.
245
+ task = {
246
+ id,
247
+ status: { state: "input-required", message: { role: "agent", parts: [{ kind: "text", text: result.pending.prompt }] } },
248
+ };
249
+ state = "input-required";
250
+ }
251
+ else {
252
+ const artifact = {
253
+ artifactId: randomUUID(),
254
+ parts: [{ kind: "text", text: result.text }],
255
+ };
256
+ task = { id, status: { state: "completed" }, artifacts: [artifact] };
257
+ state = "completed";
258
+ }
248
259
  }
249
260
  catch (e) {
250
261
  const detail = e instanceof Error ? e.message : String(e);
package/dist/toolkit.d.ts CHANGED
@@ -16,6 +16,13 @@ export interface ToolkitOptions {
16
16
  builtins?: BuiltinsConfig;
17
17
  /** Remote A2A agents — each advertised skill becomes a tool (source:"a2a"). */
18
18
  agents?: Agent[];
19
+ /**
20
+ * Host resolver for out-of-band input (§10). When set, connected MCP servers may elicit input
21
+ * from the human mid-`tools/call` and it is bridged onto this `waitFor` (form→kind:"input",
22
+ * URL→kind:"authorization"). Typically the same function passed to the client. Omit ⇒ MCP
23
+ * elicitation is not advertised.
24
+ */
25
+ waitFor?: (request: import("./types.js").Request) => Promise<import("./types.js").Answer>;
19
26
  }
20
27
  export declare class Toolkit {
21
28
  private readonly mcp;
package/dist/toolkit.js CHANGED
@@ -34,7 +34,7 @@ export class Toolkit {
34
34
  }
35
35
  }
36
36
  static async create(opts) {
37
- const mcp = opts.mcpConfig !== undefined ? await loadMcp(opts.mcpConfig) : undefined;
37
+ const mcp = opts.mcpConfig !== undefined ? await loadMcp(opts.mcpConfig, { waitFor: opts.waitFor }) : undefined;
38
38
  const skill = opts.skillsDir !== undefined ? loadSkills(opts.skillsDir) : undefined;
39
39
  // The toggle comes from the `builtins` option, or a top-level `builtins`
40
40
  // key on a parsed config object — same precedence as MCP's isEnabled.
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.7.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
@@ -331,12 +331,22 @@ async function fulfil(
331
331
  // contextId groups a peer's turns into one conversation — thread it so the
332
332
  // client's ConversationStore remembers across tasks in the same context.
333
333
  result = await runTask(text, contextId)
334
- const artifact: A2AArtifact = {
335
- artifactId: randomUUID(),
336
- parts: [{ kind: "text", text: result.text }],
334
+ if (result.status === "pending" && result.pending) {
335
+ // §10 suspension over A2A: the run halted waiting on out-of-band input. Surface the
336
+ // protocol's `input-required` state carrying the request prompt — never a false `completed`.
337
+ task = {
338
+ id,
339
+ status: { state: "input-required", message: { role: "agent", parts: [{ kind: "text", text: result.pending.prompt }] } },
340
+ }
341
+ state = "input-required"
342
+ } else {
343
+ const artifact: A2AArtifact = {
344
+ artifactId: randomUUID(),
345
+ parts: [{ kind: "text", text: result.text }],
346
+ }
347
+ task = { id, status: { state: "completed" }, artifacts: [artifact] }
348
+ state = "completed"
337
349
  }
338
- task = { id, status: { state: "completed" }, artifacts: [artifact] }
339
- state = "completed"
340
350
  } catch (e) {
341
351
  const detail = e instanceof Error ? e.message : String(e)
342
352
  task = { id, status: { state: "failed", message: { role: "agent", parts: [{ kind: "text", text: detail }] } } }
package/src/toolkit.ts CHANGED
@@ -20,6 +20,13 @@ export interface ToolkitOptions {
20
20
  builtins?: BuiltinsConfig
21
21
  /** Remote A2A agents — each advertised skill becomes a tool (source:"a2a"). */
22
22
  agents?: Agent[]
23
+ /**
24
+ * Host resolver for out-of-band input (§10). When set, connected MCP servers may elicit input
25
+ * from the human mid-`tools/call` and it is bridged onto this `waitFor` (form→kind:"input",
26
+ * URL→kind:"authorization"). Typically the same function passed to the client. Omit ⇒ MCP
27
+ * elicitation is not advertised.
28
+ */
29
+ waitFor?: (request: import("./types.js").Request) => Promise<import("./types.js").Answer>
23
30
  }
24
31
 
25
32
  export class Toolkit {
@@ -51,7 +58,7 @@ export class Toolkit {
51
58
  }
52
59
 
53
60
  static async create(opts: ToolkitOptions): Promise<Toolkit> {
54
- const mcp = opts.mcpConfig !== undefined ? await loadMcp(opts.mcpConfig) : undefined
61
+ const mcp = opts.mcpConfig !== undefined ? await loadMcp(opts.mcpConfig, { waitFor: opts.waitFor }) : undefined
55
62
  const skill = opts.skillsDir !== undefined ? loadSkills(opts.skillsDir) : undefined
56
63
  // The toggle comes from the `builtins` option, or a top-level `builtins`
57
64
  // key on a parsed config object — same precedence as MCP's isEnabled.
package/src/types.ts CHANGED
@@ -43,6 +43,12 @@ export interface Answer {
43
43
  id: string
44
44
  ok: boolean
45
45
  data?: Record<string, unknown>
46
+ /**
47
+ * When `ok` is false, why (advisory — the loop rule branches only on `ok`). Distinguishes an
48
+ * explicit refusal from a dismissal/timeout; the MCP elicitation bridge maps `declined`→`decline`
49
+ * and everything else→`cancel`. (R1)
50
+ */
51
+ reason?: "declined" | "cancelled" | "expired"
46
52
  }
47
53
 
48
54
  let _pendingSeq = 0