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 +26 -3
- package/dist/client.d.ts +3 -0
- package/dist/client.js +46 -22
- package/dist/mcp.d.ts +9 -2
- package/dist/mcp.js +36 -2
- package/dist/serve.js +17 -6
- package/dist/toolkit.d.ts +7 -0
- package/dist/toolkit.js +1 -1
- package/dist/types.d.ts +6 -0
- package/package.json +1 -1
- package/src/builtin.ts +25 -3
- package/src/client.ts +44 -20
- package/src/mcp.ts +40 -4
- package/src/serve.ts +15 -5
- package/src/toolkit.ts +8 -1
- package/src/types.ts +6 -0
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.
|
|
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
|
-
|
|
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
|
|
274
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
354
|
-
halted = r.halted;
|
|
362
|
+
halted = r.halted;
|
|
355
363
|
}
|
|
356
|
-
|
|
357
|
-
return { role: "tool", tool_call_id: call.id, content: result.output };
|
|
364
|
+
return { call, args, result, halted };
|
|
358
365
|
}));
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
452
|
-
halted = r.halted;
|
|
464
|
+
halted = r.halted;
|
|
453
465
|
}
|
|
454
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
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.
|
|
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
|
-
|
|
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
|
|
406
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
490
|
+
halted = r.halted
|
|
481
491
|
}
|
|
482
|
-
|
|
483
|
-
return { role: "tool", tool_call_id: call.id, content: result.output }
|
|
492
|
+
return { call, args, result, halted }
|
|
484
493
|
}),
|
|
485
494
|
)
|
|
486
|
-
|
|
487
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
592
|
+
halted = r.halted
|
|
578
593
|
}
|
|
579
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|