toolnexus 0.5.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/README.md CHANGED
@@ -61,6 +61,41 @@ console.log(text)
61
61
  await tk.close() // disconnect MCP servers
62
62
  ```
63
63
 
64
+ ## Suspension — login / approval / input (`Pending` / `waitFor`)
65
+
66
+ Some tools can't finish in one shot: they need an **out-of-band, async resolution** — a human
67
+ logs in, approves a trade, types a value. That's one idea, not one feature: *login* is just the
68
+ `kind:"authorization"` case. A tool returns **`Pending(request)`**; the client calls your
69
+ **`waitFor(request) → answer`**, then **retries the tool** with `ctx.answer`, and the model gets
70
+ the real answer. Everything crossing a boundary is plain data, so it works the same across
71
+ processes, agents, and restarts — and identically in all five language ports.
72
+
73
+ ```ts
74
+ import { authRequired } from "toolnexus"
75
+
76
+ // a tool that needs login the first time:
77
+ defineTool({
78
+ name: "get_balance", description: "…", inputSchema: { type: "object", properties: {} },
79
+ run: (_args, ctx) =>
80
+ authed ? `balance: ₹67,417` : authRequired("https://example.com/login", "Log in to continue"),
81
+ })
82
+
83
+ // the host supplies ONE function — how the link reaches the human is your call:
84
+ const agent = createClient({
85
+ baseUrl, style: "openai", model,
86
+ waitFor: async (request) => { // interactive: open a browser + poll
87
+ open(request.url); await pollUntilLoggedIn(); return { id: request.id, ok: true }
88
+ },
89
+ // headless desk: waitFor = send request.url to your channel + poll
90
+ // another agent: waitFor = forward the request over A2A, await its answer
91
+ })
92
+ ```
93
+
94
+ Omit `waitFor` for a **durable** host: `run()` doesn't hang — it returns
95
+ `{ status: "pending", pending: request }`, so you can deliver the link out-of-band (file, HTTP,
96
+ channel) and resume later, even in another process. `stream()` emits a `{ type: "pending", request }`
97
+ event so a channel can push the link in real time. Runnable demo: `examples/pending.ts`.
98
+
64
99
  ## Conversations / memory
65
100
 
66
101
  `run()` is stateless — each call starts fresh. To keep a thread's history, use `ask()` with an
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
@@ -4,7 +4,7 @@
4
4
  * Toolkit. See ../../SPEC.md §8.
5
5
  */
6
6
  import type { Toolkit } from "./toolkit.js";
7
- import type { ToolResult } from "./types.js";
7
+ import type { ToolResult, Request, Answer } from "./types.js";
8
8
  export type ClientStyle = "openai" | "anthropic";
9
9
  export interface ClientOptions {
10
10
  baseUrl: string;
@@ -27,6 +27,11 @@ export interface ClientOptions {
27
27
  /** Observability sink — receives semantic metric events ({event:"llm"|"tool"|"run"…}) as the
28
28
  * loop runs (§8 Observability). Forward to statsd/logs/OTel/anything. No cost when unset. */
29
29
  onMetric?: (ev: MetricEvent) => void;
30
+ /** §10 Suspension resolver. When a tool returns Pending (metadata.pending), the client calls
31
+ * this, then retries the tool with Context.answer = the resolution. Its interior is unconstrained
32
+ * (open a browser, message a channel, watch a file, forward to another agent). Omit for a durable
33
+ * host: run() does not hang — it returns { status:"pending", pending:request } to resume later. */
34
+ waitFor?: (request: Request) => Promise<Answer>;
30
35
  }
31
36
  /**
32
37
  * Semantic observability events (§8). NOT counter/histogram primitives — readable records the
@@ -46,6 +51,7 @@ export type MetricEvent = {
46
51
  source: string;
47
52
  isError: boolean;
48
53
  ms: number;
54
+ pending?: boolean;
49
55
  } | {
50
56
  event: "run";
51
57
  model: string;
@@ -148,6 +154,10 @@ export interface RunResult {
148
154
  usage: Usage;
149
155
  /** The model used. */
150
156
  model: string;
157
+ /** "done" normally; "pending" iff a tool suspended and no waitFor was configured (§10). */
158
+ status: "done" | "pending";
159
+ /** The unresolved suspension — present iff status === "pending" (§10). */
160
+ pending?: Request;
151
161
  }
152
162
  /** Events yielded by client.stream(). */
153
163
  export type StreamEvent = {
@@ -167,6 +177,9 @@ export type StreamEvent = {
167
177
  } | {
168
178
  type: "usage";
169
179
  usage: Usage;
180
+ } | {
181
+ type: "pending";
182
+ request: Request;
170
183
  } | {
171
184
  type: "done";
172
185
  result: RunResult;
@@ -225,10 +238,20 @@ export declare class Client {
225
238
  private llmFetch;
226
239
  /** Run one tool through beforeTool/afterTool hooks (mutate args, short-circuit, transform result). */
227
240
  private runTool;
241
+ /** Emit the `tool` metric, classifying a §10 suspension as `pending` (isError:false), not a failure. */
242
+ private emitTool;
228
243
  /** One non-streaming LLM call, with an `llm` metric event (ok/error + per-call tokens + ms). */
229
244
  private llmCallJson;
230
245
  private runOpenAI;
231
246
  private result;
247
+ /** §10: a run halted because a tool suspended and no `waitFor` was configured. */
248
+ private pendingRun;
249
+ /**
250
+ * §10: resolve a suspended tool result. Calls `waitFor(request)`, and on `ok` re-executes the
251
+ * tool once with `Context.answer = answer`. Returns the resolved result, or `{ halted }` when
252
+ * no `waitFor` is configured (the run should stop and surface the request).
253
+ */
254
+ private resolvePending;
232
255
  private runAnthropic;
233
256
  private streamOpenAI;
234
257
  private streamAnthropic;
package/dist/client.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { pendingOf } from "./types.js";
1
2
  /** Default conversation provider — keeps transcripts in memory for the client's lifetime. */
2
3
  export class InMemoryConversationStore {
3
4
  map = new Map();
@@ -97,7 +98,7 @@ class MetricsRegistry {
97
98
  observe(this.llmDuration, labelStr([["model", ev.model]]), ev.ms / 1000);
98
99
  }
99
100
  else if (ev.event === "tool") {
100
- 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)]]));
101
102
  observe(this.toolDuration, labelStr([["tool", ev.tool]]), ev.ms / 1000);
102
103
  }
103
104
  else if (ev.event === "run") {
@@ -269,22 +270,31 @@ export class Client {
269
270
  if (h?.beforeTool) {
270
271
  const ov = await h.beforeTool({ name, args: a, id, turn });
271
272
  if (ov?.result) {
272
- // short-circuit (deny/cache/dry-run) — still a tool call for metrics purposes
273
- 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);
274
276
  return { args: a, result: ov.result };
275
277
  }
276
278
  if (ov?.args)
277
279
  a = ov.args;
278
280
  }
279
281
  let result = await toolkit.execute(name, a);
280
- 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)) {
281
286
  const ov = await h.afterTool({ name, args: a, result, id, turn });
282
287
  if (ov?.result)
283
288
  result = ov.result;
284
289
  }
285
- this.emit({ event: "tool", tool: name, source, isError: result.isError, ms: Date.now() - t0 });
290
+ this.emitTool(name, source, result, t0);
286
291
  return { args: a, result };
287
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
+ }
288
298
  /** One non-streaming LLM call, with an `llm` metric event (ok/error + per-call tokens + ms). */
289
299
  async llmCallJson(url, init, signal, style) {
290
300
  const t0 = Date.now();
@@ -342,12 +352,26 @@ export class Client {
342
352
  if (calls.length === 0)
343
353
  return this.endRun(runStart, msg.content ?? "", messages, toolCalls, turns, usage);
344
354
  // execute all tool calls in this turn concurrently (true parallel tool calling)
345
- const results = await Promise.all(calls.map(async (call) => {
346
- const { args, result } = await this.runTool(toolkit, call.function.name, safeJson(call.function.arguments), call.id, turn);
347
- toolCalls.push({ name: call.function.name, args, output: result.output, isError: result.isError, metadata: result.metadata });
348
- return { role: "tool", tool_call_id: call.id, content: result.output };
355
+ const settled = await Promise.all(calls.map(async (call) => {
356
+ let { args, result } = await this.runTool(toolkit, call.function.name, safeJson(call.function.arguments), call.id, turn);
357
+ let halted;
358
+ const req = pendingOf(result);
359
+ if (req) {
360
+ const r = await this.resolvePending(toolkit, call.function.name, args, req, call.id, turn);
361
+ result = r.result;
362
+ halted = r.halted;
363
+ }
364
+ return { call, args, result, halted };
349
365
  }));
350
- messages.push(...results);
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
+ }
351
375
  }
352
376
  return this.endRun(runStart, lastText(messages), messages, toolCalls, turns, usage);
353
377
  }
@@ -357,7 +381,35 @@ export class Client {
357
381
  }
358
382
  }
359
383
  result(text, messages, toolCalls, turns, usage) {
360
- return { text, messages, toolCalls, toolCallCount: toolCalls.length, turns, usage, model: this.opts.model };
384
+ return { text, messages, toolCalls, toolCallCount: toolCalls.length, turns, usage, model: this.opts.model, status: "done" };
385
+ }
386
+ /** §10: a run halted because a tool suspended and no `waitFor` was configured. */
387
+ pendingRun(runStart, request, messages, toolCalls, turns, usage) {
388
+ this.emit({ event: "run", model: this.opts.model, turns, toolCalls: toolCalls.length, totalTokens: usage.totalTokens, ms: Date.now() - runStart });
389
+ return { text: request.prompt, messages, toolCalls, toolCallCount: toolCalls.length, turns, usage, model: this.opts.model, status: "pending", pending: request };
390
+ }
391
+ /**
392
+ * §10: resolve a suspended tool result. Calls `waitFor(request)`, and on `ok` re-executes the
393
+ * tool once with `Context.answer = answer`. Returns the resolved result, or `{ halted }` when
394
+ * no `waitFor` is configured (the run should stop and surface the request).
395
+ */
396
+ async resolvePending(toolkit, name, args, request, id, turn) {
397
+ if (!this.opts.waitFor)
398
+ return { result: { output: request.prompt, isError: true }, halted: request };
399
+ const answer = await this.opts.waitFor(request);
400
+ if (!answer?.ok)
401
+ return { result: { output: `declined/expired: ${request.prompt}`, isError: true } };
402
+ const t0 = Date.now();
403
+ let result = await toolkit.execute(name, args, { answer });
404
+ if (this.opts.hooks?.afterTool) {
405
+ const ov = await this.opts.hooks.afterTool({ name, args, result, id, turn });
406
+ if (ov?.result)
407
+ result = ov.result;
408
+ }
409
+ this.emitTool(name, toolkit.get(name)?.source ?? "custom", result, t0);
410
+ if (pendingOf(result))
411
+ result = { output: `unresolved: ${request.prompt}`, isError: true }; // never loop forever
412
+ return { result };
361
413
  }
362
414
  // ---- Anthropic-style: POST {baseUrl}/messages ----
363
415
  async runAnthropic(prompt, toolkit, external, history) {
@@ -402,12 +454,33 @@ export class Client {
402
454
  return this.endRun(runStart, text, messages, toolCalls, turns, usage);
403
455
  }
404
456
  // execute all tool_use blocks in this turn concurrently (true parallel tool calling)
405
- const results = await Promise.all(uses.map(async (use) => {
406
- const { args, result } = await this.runTool(toolkit, use.name, use.input ?? {}, use.id, turn);
407
- toolCalls.push({ name: use.name, args, output: result.output, isError: result.isError, metadata: result.metadata });
408
- return { type: "tool_result", tool_use_id: use.id, content: result.output, is_error: result.isError };
457
+ const settled = await Promise.all(uses.map(async (use) => {
458
+ let { args, result } = await this.runTool(toolkit, use.name, use.input ?? {}, use.id, turn);
459
+ let halted;
460
+ const req = pendingOf(result);
461
+ if (req) {
462
+ const r = await this.resolvePending(toolkit, use.name, args, req, use.id, turn);
463
+ result = r.result;
464
+ halted = r.halted;
465
+ }
466
+ return { use, args, result, halted };
409
467
  }));
410
- 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 });
482
+ if (halted)
483
+ return this.pendingRun(runStart, halted, messages, toolCalls, turns, usage);
411
484
  }
412
485
  return this.endRun(runStart, "", messages, toolCalls, turns, usage);
413
486
  }
@@ -500,7 +573,20 @@ export class Client {
500
573
  yield { type: "tool_call", id: c.id, name: c.name, args: safeJson(c.args) };
501
574
  const settled = await Promise.all(calls.map((c) => this.runTool(toolkit, c.name, safeJson(c.args), c.id, turn)));
502
575
  for (let i = 0; i < calls.length; i++) {
503
- const c = calls[i], { args, result } = settled[i];
576
+ const c = calls[i];
577
+ let { args, result } = settled[i];
578
+ const req = pendingOf(result);
579
+ if (req) {
580
+ yield { type: "pending", request: req }; // surface BEFORE waitFor so a channel can push the link
581
+ const r = await this.resolvePending(toolkit, c.name, args, req, c.id, turn);
582
+ result = r.result;
583
+ if (r.halted) {
584
+ toolCalls.push({ name: c.name, args, output: result.output, isError: result.isError, metadata: result.metadata });
585
+ messages.push({ role: "tool", tool_call_id: c.id, content: result.output });
586
+ yield { type: "done", result: this.pendingRun(runStart, r.halted, messages, toolCalls, turns, usage) };
587
+ return;
588
+ }
589
+ }
504
590
  toolCalls.push({ name: c.name, args, output: result.output, isError: result.isError, metadata: result.metadata });
505
591
  messages.push({ role: "tool", tool_call_id: c.id, content: result.output });
506
592
  yield { type: "tool_result", id: c.id, name: c.name, output: result.output, isError: result.isError };
@@ -594,7 +680,21 @@ export class Client {
594
680
  const settled = await Promise.all(uses.map((u) => this.runTool(toolkit, u.name, u.input ?? {}, u.id, turn)));
595
681
  const results = [];
596
682
  for (let i = 0; i < uses.length; i++) {
597
- const u = uses[i], { args, result } = settled[i];
683
+ const u = uses[i];
684
+ let { args, result } = settled[i];
685
+ const req = pendingOf(result);
686
+ if (req) {
687
+ yield { type: "pending", request: req };
688
+ const r = await this.resolvePending(toolkit, u.name, args, req, u.id, turn);
689
+ result = r.result;
690
+ if (r.halted) {
691
+ toolCalls.push({ name: u.name, args, output: result.output, isError: result.isError, metadata: result.metadata });
692
+ results.push({ type: "tool_result", tool_use_id: u.id, content: result.output, is_error: result.isError });
693
+ messages.push({ role: "user", content: results });
694
+ yield { type: "done", result: this.pendingRun(runStart, r.halted, messages, toolCalls, turns, usage) };
695
+ return;
696
+ }
697
+ }
598
698
  toolCalls.push({ name: u.name, args, output: result.output, isError: result.isError, metadata: result.metadata });
599
699
  results.push({ type: "tool_result", tool_use_id: u.id, content: result.output, is_error: result.isError });
600
700
  yield { type: "tool_result", id: u.id, name: u.name, output: result.output, isError: result.isError };
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
@@ -17,7 +17,41 @@ export interface ToolResult {
17
17
  export interface ToolContext {
18
18
  signal?: AbortSignal;
19
19
  timeout?: number;
20
+ /** Present ONLY on a post-waitFor retry (§10): the resolution of a prior suspension. */
21
+ answer?: Answer;
20
22
  }
23
+ /**
24
+ * §10 Suspension. A tool that needs an out-of-band, async resolution (login, approval,
25
+ * input) returns a normal ToolResult carrying `metadata.pending = Request`. Byte-identical
26
+ * wire data — it crosses languages, processes, and agents unchanged.
27
+ */
28
+ export interface Request {
29
+ id: string;
30
+ kind: string;
31
+ prompt: string;
32
+ url?: string;
33
+ data?: Record<string, unknown>;
34
+ expiresAt?: string;
35
+ }
36
+ export interface Answer {
37
+ id: string;
38
+ ok: boolean;
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";
46
+ }
47
+ /** Producer helper: return a suspension. A ToolResult with `metadata.pending` = a Request. */
48
+ export declare function pending(request: Omit<Request, "id"> & {
49
+ id?: string;
50
+ }): ToolResult;
51
+ /** Sugar for the common case: `kind:"authorization"` at a login URL. */
52
+ export declare function authRequired(url: string, prompt?: string): ToolResult;
53
+ /** Read the suspension off a result, if any. */
54
+ export declare function pendingOf(result: ToolResult): Request | undefined;
21
55
  export interface Tool {
22
56
  name: string;
23
57
  description: string;
package/dist/types.js CHANGED
@@ -1,5 +1,24 @@
1
1
  /**
2
2
  * Shared contract types. See ../../SPEC.md.
3
3
  */
4
+ let _pendingSeq = 0;
5
+ /** Producer helper: return a suspension. A ToolResult with `metadata.pending` = a Request. */
6
+ export function pending(request) {
7
+ const id = request.id ?? `pnd-${Date.now().toString(36)}-${++_pendingSeq}`;
8
+ const req = { ...request, id };
9
+ return {
10
+ output: req.prompt + (req.url ? `\n${req.url}` : ""),
11
+ isError: true,
12
+ metadata: { pending: req },
13
+ };
14
+ }
15
+ /** Sugar for the common case: `kind:"authorization"` at a login URL. */
16
+ export function authRequired(url, prompt = "Authorization required to continue") {
17
+ return pending({ kind: "authorization", prompt, url });
18
+ }
19
+ /** Read the suspension off a result, if any. */
20
+ export function pendingOf(result) {
21
+ return result.metadata?.pending;
22
+ }
4
23
  /** Replace anything outside [a-zA-Z0-9_-] with "_" (same as opencode). */
5
24
  export const sanitize = (value) => value.replace(/[^a-zA-Z0-9_-]/g, "_");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolnexus",
3
- "version": "0.5.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
@@ -4,7 +4,8 @@
4
4
  * Toolkit. See ../../SPEC.md §8.
5
5
  */
6
6
  import type { Toolkit } from "./toolkit.js"
7
- import type { ToolResult } from "./types.js"
7
+ import type { ToolResult, Request, Answer } from "./types.js"
8
+ import { pendingOf } from "./types.js"
8
9
 
9
10
  export type ClientStyle = "openai" | "anthropic"
10
11
 
@@ -29,6 +30,11 @@ export interface ClientOptions {
29
30
  /** Observability sink — receives semantic metric events ({event:"llm"|"tool"|"run"…}) as the
30
31
  * loop runs (§8 Observability). Forward to statsd/logs/OTel/anything. No cost when unset. */
31
32
  onMetric?: (ev: MetricEvent) => void
33
+ /** §10 Suspension resolver. When a tool returns Pending (metadata.pending), the client calls
34
+ * this, then retries the tool with Context.answer = the resolution. Its interior is unconstrained
35
+ * (open a browser, message a channel, watch a file, forward to another agent). Omit for a durable
36
+ * host: run() does not hang — it returns { status:"pending", pending:request } to resume later. */
37
+ waitFor?: (request: Request) => Promise<Answer>
32
38
  }
33
39
 
34
40
  /**
@@ -38,7 +44,7 @@ export interface ClientOptions {
38
44
  */
39
45
  export type MetricEvent =
40
46
  | { event: "llm"; model: string; status: "ok" | "error"; ms: number; promptTokens: number; completionTokens: number }
41
- | { event: "tool"; tool: string; source: string; isError: boolean; ms: number }
47
+ | { event: "tool"; tool: string; source: string; isError: boolean; ms: number; pending?: boolean }
42
48
  | { event: "run"; model: string; turns: number; toolCalls: number; totalTokens: number; ms: number; error?: string }
43
49
 
44
50
  /**
@@ -117,6 +123,10 @@ export interface RunResult {
117
123
  usage: Usage
118
124
  /** The model used. */
119
125
  model: string
126
+ /** "done" normally; "pending" iff a tool suspended and no waitFor was configured (§10). */
127
+ status: "done" | "pending"
128
+ /** The unresolved suspension — present iff status === "pending" (§10). */
129
+ pending?: Request
120
130
  }
121
131
 
122
132
  /** Events yielded by client.stream(). */
@@ -125,6 +135,7 @@ export type StreamEvent =
125
135
  | { type: "tool_call"; id: string; name: string; args: Record<string, unknown> }
126
136
  | { type: "tool_result"; id: string; name: string; output: string; isError: boolean }
127
137
  | { type: "usage"; usage: Usage }
138
+ | { type: "pending"; request: Request }
128
139
  | { type: "done"; result: RunResult }
129
140
 
130
141
  function addUsage(acc: Usage, raw: any, style: ClientStyle): void {
@@ -216,7 +227,7 @@ class MetricsRegistry {
216
227
  }
217
228
  observe(this.llmDuration, labelStr([["model", ev.model]]), ev.ms / 1000)
218
229
  } else if (ev.event === "tool") {
219
- 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)]]))
220
231
  observe(this.toolDuration, labelStr([["tool", ev.tool]]), ev.ms / 1000)
221
232
  } else if (ev.event === "run") {
222
233
  if (ev.error) inc(this.runErrors, labelStr([["model", ev.model]]))
@@ -391,21 +402,31 @@ export class Client {
391
402
  if (h?.beforeTool) {
392
403
  const ov = await h.beforeTool({ name, args: a, id, turn })
393
404
  if (ov?.result) {
394
- // short-circuit (deny/cache/dry-run) — still a tool call for metrics purposes
395
- 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)
396
408
  return { args: a, result: ov.result }
397
409
  }
398
410
  if (ov?.args) a = ov.args
399
411
  }
400
412
  let result = await toolkit.execute(name, a)
401
- 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)) {
402
417
  const ov = await h.afterTool({ name, args: a, result, id, turn })
403
418
  if (ov?.result) result = ov.result
404
419
  }
405
- this.emit({ event: "tool", tool: name, source, isError: result.isError, ms: Date.now() - t0 })
420
+ this.emitTool(name, source, result, t0)
406
421
  return { args: a, result }
407
422
  }
408
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
+
409
430
  /** One non-streaming LLM call, with an `llm` metric event (ok/error + per-call tokens + ms). */
410
431
  private async llmCallJson(url: string, init: RequestInit, signal: AbortSignal, style: ClientStyle): Promise<any> {
411
432
  const t0 = Date.now()
@@ -458,14 +479,27 @@ export class Client {
458
479
  const calls = msg.tool_calls ?? []
459
480
  if (calls.length === 0) return this.endRun(runStart, msg.content ?? "", messages, toolCalls, turns, usage)
460
481
  // execute all tool calls in this turn concurrently (true parallel tool calling)
461
- const results = await Promise.all(
482
+ const settled = await Promise.all(
462
483
  calls.map(async (call: any) => {
463
- const { args, result } = await this.runTool(toolkit, call.function.name, safeJson(call.function.arguments), call.id, turn)
464
- toolCalls.push({ name: call.function.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
465
- return { role: "tool", tool_call_id: call.id, content: result.output }
484
+ let { args, result } = await this.runTool(toolkit, call.function.name, safeJson(call.function.arguments), call.id, turn)
485
+ let halted: Request | undefined
486
+ const req = pendingOf(result)
487
+ if (req) {
488
+ const r = await this.resolvePending(toolkit, call.function.name, args, req, call.id, turn)
489
+ result = r.result
490
+ halted = r.halted
491
+ }
492
+ return { call, args, result, halted }
466
493
  }),
467
494
  )
468
- messages.push(...results)
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
+ }
469
503
  }
470
504
  return this.endRun(runStart, lastText(messages), messages, toolCalls, turns, usage)
471
505
  } catch (e) {
@@ -475,7 +509,35 @@ export class Client {
475
509
  }
476
510
 
477
511
  private result(text: string, messages: any[], toolCalls: ToolCallRecord[], turns: number, usage: Usage): RunResult {
478
- return { text, messages, toolCalls, toolCallCount: toolCalls.length, turns, usage, model: this.opts.model }
512
+ return { text, messages, toolCalls, toolCallCount: toolCalls.length, turns, usage, model: this.opts.model, status: "done" }
513
+ }
514
+
515
+ /** §10: a run halted because a tool suspended and no `waitFor` was configured. */
516
+ private pendingRun(runStart: number, request: Request, messages: any[], toolCalls: ToolCallRecord[], turns: number, usage: Usage): RunResult {
517
+ this.emit({ event: "run", model: this.opts.model, turns, toolCalls: toolCalls.length, totalTokens: usage.totalTokens, ms: Date.now() - runStart })
518
+ return { text: request.prompt, messages, toolCalls, toolCallCount: toolCalls.length, turns, usage, model: this.opts.model, status: "pending", pending: request }
519
+ }
520
+
521
+ /**
522
+ * §10: resolve a suspended tool result. Calls `waitFor(request)`, and on `ok` re-executes the
523
+ * tool once with `Context.answer = answer`. Returns the resolved result, or `{ halted }` when
524
+ * no `waitFor` is configured (the run should stop and surface the request).
525
+ */
526
+ private async resolvePending(
527
+ toolkit: Toolkit, name: string, args: Record<string, unknown>, request: Request, id: string | undefined, turn: number,
528
+ ): Promise<{ result: ToolResult; halted?: Request }> {
529
+ if (!this.opts.waitFor) return { result: { output: request.prompt, isError: true }, halted: request }
530
+ const answer = await this.opts.waitFor(request)
531
+ if (!answer?.ok) return { result: { output: `declined/expired: ${request.prompt}`, isError: true } }
532
+ const t0 = Date.now()
533
+ let result = await toolkit.execute(name, args, { answer })
534
+ if (this.opts.hooks?.afterTool) {
535
+ const ov = await this.opts.hooks.afterTool({ name, args, result, id, turn })
536
+ if (ov?.result) result = ov.result
537
+ }
538
+ this.emitTool(name, toolkit.get(name)?.source ?? "custom", result, t0)
539
+ if (pendingOf(result)) result = { output: `unresolved: ${request.prompt}`, isError: true } // never loop forever
540
+ return { result }
479
541
  }
480
542
 
481
543
  // ---- Anthropic-style: POST {baseUrl}/messages ----
@@ -519,14 +581,31 @@ export class Client {
519
581
  return this.endRun(runStart, text, messages, toolCalls, turns, usage)
520
582
  }
521
583
  // execute all tool_use blocks in this turn concurrently (true parallel tool calling)
522
- const results = await Promise.all(
584
+ const settled = await Promise.all(
523
585
  uses.map(async (use: any) => {
524
- const { args, result } = await this.runTool(toolkit, use.name, use.input ?? {}, use.id, turn)
525
- toolCalls.push({ name: use.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
526
- return { type: "tool_result", tool_use_id: use.id, content: result.output, is_error: result.isError }
586
+ let { args, result } = await this.runTool(toolkit, use.name, use.input ?? {}, use.id, turn)
587
+ let halted: Request | undefined
588
+ const req = pendingOf(result)
589
+ if (req) {
590
+ const r = await this.resolvePending(toolkit, use.name, args, req, use.id, turn)
591
+ result = r.result
592
+ halted = r.halted
593
+ }
594
+ return { use, args, result, halted }
527
595
  }),
528
596
  )
529
- 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 })
608
+ if (halted) return this.pendingRun(runStart, halted, messages, toolCalls, turns, usage)
530
609
  }
531
610
  return this.endRun(runStart, "", messages, toolCalls, turns, usage)
532
611
  } catch (e) {
@@ -605,7 +684,20 @@ export class Client {
605
684
  for (const c of calls) yield { type: "tool_call", id: c.id, name: c.name, args: safeJson(c.args) }
606
685
  const settled = await Promise.all(calls.map((c) => this.runTool(toolkit, c.name, safeJson(c.args), c.id, turn)))
607
686
  for (let i = 0; i < calls.length; i++) {
608
- const c = calls[i], { args, result } = settled[i]
687
+ const c = calls[i]
688
+ let { args, result } = settled[i]
689
+ const req = pendingOf(result)
690
+ if (req) {
691
+ yield { type: "pending", request: req } // surface BEFORE waitFor so a channel can push the link
692
+ const r = await this.resolvePending(toolkit, c.name, args, req, c.id, turn)
693
+ result = r.result
694
+ if (r.halted) {
695
+ toolCalls.push({ name: c.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
696
+ messages.push({ role: "tool", tool_call_id: c.id, content: result.output })
697
+ yield { type: "done", result: this.pendingRun(runStart, r.halted, messages, toolCalls, turns, usage) }
698
+ return
699
+ }
700
+ }
609
701
  toolCalls.push({ name: c.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
610
702
  messages.push({ role: "tool", tool_call_id: c.id, content: result.output })
611
703
  yield { type: "tool_result", id: c.id, name: c.name, output: result.output, isError: result.isError }
@@ -683,7 +775,21 @@ export class Client {
683
775
  const settled = await Promise.all((uses as any[]).map((u) => this.runTool(toolkit, u.name, u.input ?? {}, u.id, turn)))
684
776
  const results: any[] = []
685
777
  for (let i = 0; i < uses.length; i++) {
686
- const u = uses[i] as any, { args, result } = settled[i]
778
+ const u = uses[i] as any
779
+ let { args, result } = settled[i]
780
+ const req = pendingOf(result)
781
+ if (req) {
782
+ yield { type: "pending", request: req }
783
+ const r = await this.resolvePending(toolkit, u.name, args, req, u.id, turn)
784
+ result = r.result
785
+ if (r.halted) {
786
+ toolCalls.push({ name: u.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
787
+ results.push({ type: "tool_result", tool_use_id: u.id, content: result.output, is_error: result.isError })
788
+ messages.push({ role: "user", content: results })
789
+ yield { type: "done", result: this.pendingRun(runStart, r.halted, messages, toolCalls, turns, usage) }
790
+ return
791
+ }
792
+ }
687
793
  toolCalls.push({ name: u.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
688
794
  results.push({ type: "tool_result", tool_use_id: u.id, content: result.output, is_error: result.isError })
689
795
  yield { type: "tool_result", id: u.id, name: u.name, output: result.output, isError: result.isError }
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
@@ -21,6 +21,56 @@ export interface ToolResult {
21
21
  export interface ToolContext {
22
22
  signal?: AbortSignal
23
23
  timeout?: number
24
+ /** Present ONLY on a post-waitFor retry (§10): the resolution of a prior suspension. */
25
+ answer?: Answer
26
+ }
27
+
28
+ /**
29
+ * §10 Suspension. A tool that needs an out-of-band, async resolution (login, approval,
30
+ * input) returns a normal ToolResult carrying `metadata.pending = Request`. Byte-identical
31
+ * wire data — it crosses languages, processes, and agents unchanged.
32
+ */
33
+ export interface Request {
34
+ id: string
35
+ kind: string // "authorization" | "approval" | "input" | ... (open vocabulary)
36
+ prompt: string
37
+ url?: string
38
+ data?: Record<string, unknown>
39
+ expiresAt?: string
40
+ }
41
+
42
+ export interface Answer {
43
+ id: string
44
+ ok: boolean
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"
52
+ }
53
+
54
+ let _pendingSeq = 0
55
+ /** Producer helper: return a suspension. A ToolResult with `metadata.pending` = a Request. */
56
+ export function pending(request: Omit<Request, "id"> & { id?: string }): ToolResult {
57
+ const id = request.id ?? `pnd-${Date.now().toString(36)}-${++_pendingSeq}`
58
+ const req: Request = { ...request, id }
59
+ return {
60
+ output: req.prompt + (req.url ? `\n${req.url}` : ""),
61
+ isError: true,
62
+ metadata: { pending: req },
63
+ }
64
+ }
65
+
66
+ /** Sugar for the common case: `kind:"authorization"` at a login URL. */
67
+ export function authRequired(url: string, prompt = "Authorization required to continue"): ToolResult {
68
+ return pending({ kind: "authorization", prompt, url })
69
+ }
70
+
71
+ /** Read the suspension off a result, if any. */
72
+ export function pendingOf(result: ToolResult): Request | undefined {
73
+ return (result.metadata as { pending?: Request } | undefined)?.pending
24
74
  }
25
75
 
26
76
  export interface Tool {