toolnexus 0.4.0 → 0.6.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 +59 -1
- package/dist/client.d.ts +21 -1
- package/dist/client.js +81 -5
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/mcp.js +3 -3
- package/dist/mcpserve.d.ts +47 -0
- package/dist/mcpserve.js +69 -0
- package/dist/serve.d.ts +8 -0
- package/dist/serve.js +42 -2
- package/dist/toolkit.d.ts +6 -1
- package/dist/toolkit.js +17 -2
- package/dist/types.d.ts +28 -0
- package/dist/types.js +19 -0
- package/package.json +1 -1
- package/src/client.ts +88 -6
- package/src/index.ts +1 -0
- package/src/mcp.ts +3 -3
- package/src/mcpserve.ts +95 -0
- package/src/serve.ts +51 -3
- package/src/toolkit.ts +22 -4
- package/src/types.ts +44 -0
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
|
|
@@ -268,6 +303,29 @@ Task persistence is a pluggable `TaskStore` — in-memory default, `"file:<dir>"
|
|
|
268
303
|
it **remembers**: a message's A2A `contextId` keys the conversation through `client.ask`, so a
|
|
269
304
|
peer's turns in the same context carry history via the client's `ConversationStore`.
|
|
270
305
|
|
|
306
|
+
## Serve as an MCP server (be a gateway)
|
|
307
|
+
|
|
308
|
+
The inbound mirror of A2A: expose your **whole toolkit as an MCP server** so any MCP client — an IDE,
|
|
309
|
+
another agent, a remote host — can call its tools. Point toolnexus at N MCP servers + skills + your
|
|
310
|
+
own functions, then re-expose the union as **one** MCP server. Unlike A2A (which advertises skills and
|
|
311
|
+
runs the client loop), the MCP client *is* the LLM host, so each `tools/call` dispatches straight to
|
|
312
|
+
`Tool.execute` — no `client`, no tasks, no store.
|
|
313
|
+
|
|
314
|
+
```ts
|
|
315
|
+
// streamable-HTTP — an embeddable MCP server mounted at POST /mcp, beside any A2A routes:
|
|
316
|
+
const srv = await tk.serve("127.0.0.1:0", {
|
|
317
|
+
mcp: { name: "my-gateway" /*, tools: ["echo"] // subset; omit ⇒ every toolkit tool */ },
|
|
318
|
+
onCall: (ev) => console.error(ev.name, ev.ms, ev.isError),
|
|
319
|
+
})
|
|
320
|
+
console.log(srv.url + "/mcp") // connect any MCP client here
|
|
321
|
+
await srv.stop()
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
`tools/list` advertises every toolkit tool (name **verbatim**, `inputSchema` = the tool's parameters);
|
|
325
|
+
`mcp.tools` narrows the surface. The `mcp` profile can also live in the config file as a top-level
|
|
326
|
+
**`mcpServer`** block (singular — distinct from the client-side `mcpServers`). (Transport is
|
|
327
|
+
streamable-HTTP; a stdio transport for local clients like Claude Desktop is a planned follow-up.)
|
|
328
|
+
|
|
271
329
|
## Bring your own loop
|
|
272
330
|
|
|
273
331
|
Don't want the host loop? Use the schema adapters and execute calls yourself:
|
|
@@ -309,7 +367,7 @@ a sixth.
|
|
|
309
367
|
| `tk.skillsPrompt()` | system-prompt skill catalog |
|
|
310
368
|
| `tk.mcpStatus()` | per-server connection status |
|
|
311
369
|
| `tk.toOpenAI()` / `toAnthropic()` / `toGemini()` | provider tool schemas |
|
|
312
|
-
| `tk.serve(addr, { client
|
|
370
|
+
| `tk.serve(addr, { client?, a2a?, onTask?, mcp?, onCall? })` | serve the toolkit over HTTP — A2A agent and/or streamable-HTTP MCP server (`/mcp`) → `ServeHandle` |
|
|
313
371
|
| `tk.addAgent(cardUrl \| Agent)` | fetch a remote agent's card and register its skills as tools |
|
|
314
372
|
| `tk.close()` | disconnect MCP servers |
|
|
315
373
|
| `defineTool` / `httpTool` / `agent` | build native / HTTP / A2A tools |
|
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
|
|
@@ -148,6 +153,10 @@ export interface RunResult {
|
|
|
148
153
|
usage: Usage;
|
|
149
154
|
/** The model used. */
|
|
150
155
|
model: string;
|
|
156
|
+
/** "done" normally; "pending" iff a tool suspended and no waitFor was configured (§10). */
|
|
157
|
+
status: "done" | "pending";
|
|
158
|
+
/** The unresolved suspension — present iff status === "pending" (§10). */
|
|
159
|
+
pending?: Request;
|
|
151
160
|
}
|
|
152
161
|
/** Events yielded by client.stream(). */
|
|
153
162
|
export type StreamEvent = {
|
|
@@ -167,6 +176,9 @@ export type StreamEvent = {
|
|
|
167
176
|
} | {
|
|
168
177
|
type: "usage";
|
|
169
178
|
usage: Usage;
|
|
179
|
+
} | {
|
|
180
|
+
type: "pending";
|
|
181
|
+
request: Request;
|
|
170
182
|
} | {
|
|
171
183
|
type: "done";
|
|
172
184
|
result: RunResult;
|
|
@@ -229,6 +241,14 @@ export declare class Client {
|
|
|
229
241
|
private llmCallJson;
|
|
230
242
|
private runOpenAI;
|
|
231
243
|
private result;
|
|
244
|
+
/** §10: a run halted because a tool suspended and no `waitFor` was configured. */
|
|
245
|
+
private pendingRun;
|
|
246
|
+
/**
|
|
247
|
+
* §10: resolve a suspended tool result. Calls `waitFor(request)`, and on `ok` re-executes the
|
|
248
|
+
* tool once with `Context.answer = answer`. Returns the resolved result, or `{ halted }` when
|
|
249
|
+
* no `waitFor` is configured (the run should stop and surface the request).
|
|
250
|
+
*/
|
|
251
|
+
private resolvePending;
|
|
232
252
|
private runAnthropic;
|
|
233
253
|
private streamOpenAI;
|
|
234
254
|
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();
|
|
@@ -342,12 +343,22 @@ export class Client {
|
|
|
342
343
|
if (calls.length === 0)
|
|
343
344
|
return this.endRun(runStart, msg.content ?? "", messages, toolCalls, turns, usage);
|
|
344
345
|
// execute all tool calls in this turn concurrently (true parallel tool calling)
|
|
346
|
+
let halted;
|
|
345
347
|
const results = await Promise.all(calls.map(async (call) => {
|
|
346
|
-
|
|
348
|
+
let { args, result } = await this.runTool(toolkit, call.function.name, safeJson(call.function.arguments), call.id, turn);
|
|
349
|
+
const req = pendingOf(result);
|
|
350
|
+
if (req) {
|
|
351
|
+
const r = await this.resolvePending(toolkit, call.function.name, args, req, call.id, turn);
|
|
352
|
+
result = r.result;
|
|
353
|
+
if (r.halted)
|
|
354
|
+
halted = r.halted;
|
|
355
|
+
}
|
|
347
356
|
toolCalls.push({ name: call.function.name, args, output: result.output, isError: result.isError, metadata: result.metadata });
|
|
348
357
|
return { role: "tool", tool_call_id: call.id, content: result.output };
|
|
349
358
|
}));
|
|
350
359
|
messages.push(...results);
|
|
360
|
+
if (halted)
|
|
361
|
+
return this.pendingRun(runStart, halted, messages, toolCalls, turns, usage);
|
|
351
362
|
}
|
|
352
363
|
return this.endRun(runStart, lastText(messages), messages, toolCalls, turns, usage);
|
|
353
364
|
}
|
|
@@ -357,7 +368,35 @@ export class Client {
|
|
|
357
368
|
}
|
|
358
369
|
}
|
|
359
370
|
result(text, messages, toolCalls, turns, usage) {
|
|
360
|
-
return { text, messages, toolCalls, toolCallCount: toolCalls.length, turns, usage, model: this.opts.model };
|
|
371
|
+
return { text, messages, toolCalls, toolCallCount: toolCalls.length, turns, usage, model: this.opts.model, status: "done" };
|
|
372
|
+
}
|
|
373
|
+
/** §10: a run halted because a tool suspended and no `waitFor` was configured. */
|
|
374
|
+
pendingRun(runStart, request, messages, toolCalls, turns, usage) {
|
|
375
|
+
this.emit({ event: "run", model: this.opts.model, turns, toolCalls: toolCalls.length, totalTokens: usage.totalTokens, ms: Date.now() - runStart });
|
|
376
|
+
return { text: request.prompt, messages, toolCalls, toolCallCount: toolCalls.length, turns, usage, model: this.opts.model, status: "pending", pending: request };
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* §10: resolve a suspended tool result. Calls `waitFor(request)`, and on `ok` re-executes the
|
|
380
|
+
* tool once with `Context.answer = answer`. Returns the resolved result, or `{ halted }` when
|
|
381
|
+
* no `waitFor` is configured (the run should stop and surface the request).
|
|
382
|
+
*/
|
|
383
|
+
async resolvePending(toolkit, name, args, request, id, turn) {
|
|
384
|
+
if (!this.opts.waitFor)
|
|
385
|
+
return { result: { output: request.prompt, isError: true }, halted: request };
|
|
386
|
+
const answer = await this.opts.waitFor(request);
|
|
387
|
+
if (!answer?.ok)
|
|
388
|
+
return { result: { output: `declined/expired: ${request.prompt}`, isError: true } };
|
|
389
|
+
const t0 = Date.now();
|
|
390
|
+
let result = await toolkit.execute(name, args, { answer });
|
|
391
|
+
if (this.opts.hooks?.afterTool) {
|
|
392
|
+
const ov = await this.opts.hooks.afterTool({ name, args, result, id, turn });
|
|
393
|
+
if (ov?.result)
|
|
394
|
+
result = ov.result;
|
|
395
|
+
}
|
|
396
|
+
this.emit({ event: "tool", tool: name, source: toolkit.get(name)?.source ?? "custom", isError: result.isError, ms: Date.now() - t0 });
|
|
397
|
+
if (pendingOf(result))
|
|
398
|
+
result = { output: `unresolved: ${request.prompt}`, isError: true }; // never loop forever
|
|
399
|
+
return { result };
|
|
361
400
|
}
|
|
362
401
|
// ---- Anthropic-style: POST {baseUrl}/messages ----
|
|
363
402
|
async runAnthropic(prompt, toolkit, external, history) {
|
|
@@ -402,12 +441,22 @@ export class Client {
|
|
|
402
441
|
return this.endRun(runStart, text, messages, toolCalls, turns, usage);
|
|
403
442
|
}
|
|
404
443
|
// execute all tool_use blocks in this turn concurrently (true parallel tool calling)
|
|
444
|
+
let halted;
|
|
405
445
|
const results = await Promise.all(uses.map(async (use) => {
|
|
406
|
-
|
|
446
|
+
let { args, result } = await this.runTool(toolkit, use.name, use.input ?? {}, use.id, turn);
|
|
447
|
+
const req = pendingOf(result);
|
|
448
|
+
if (req) {
|
|
449
|
+
const r = await this.resolvePending(toolkit, use.name, args, req, use.id, turn);
|
|
450
|
+
result = r.result;
|
|
451
|
+
if (r.halted)
|
|
452
|
+
halted = r.halted;
|
|
453
|
+
}
|
|
407
454
|
toolCalls.push({ name: use.name, args, output: result.output, isError: result.isError, metadata: result.metadata });
|
|
408
455
|
return { type: "tool_result", tool_use_id: use.id, content: result.output, is_error: result.isError };
|
|
409
456
|
}));
|
|
410
457
|
messages.push({ role: "user", content: results });
|
|
458
|
+
if (halted)
|
|
459
|
+
return this.pendingRun(runStart, halted, messages, toolCalls, turns, usage);
|
|
411
460
|
}
|
|
412
461
|
return this.endRun(runStart, "", messages, toolCalls, turns, usage);
|
|
413
462
|
}
|
|
@@ -500,7 +549,20 @@ export class Client {
|
|
|
500
549
|
yield { type: "tool_call", id: c.id, name: c.name, args: safeJson(c.args) };
|
|
501
550
|
const settled = await Promise.all(calls.map((c) => this.runTool(toolkit, c.name, safeJson(c.args), c.id, turn)));
|
|
502
551
|
for (let i = 0; i < calls.length; i++) {
|
|
503
|
-
const c = calls[i]
|
|
552
|
+
const c = calls[i];
|
|
553
|
+
let { args, result } = settled[i];
|
|
554
|
+
const req = pendingOf(result);
|
|
555
|
+
if (req) {
|
|
556
|
+
yield { type: "pending", request: req }; // surface BEFORE waitFor so a channel can push the link
|
|
557
|
+
const r = await this.resolvePending(toolkit, c.name, args, req, c.id, turn);
|
|
558
|
+
result = r.result;
|
|
559
|
+
if (r.halted) {
|
|
560
|
+
toolCalls.push({ name: c.name, args, output: result.output, isError: result.isError, metadata: result.metadata });
|
|
561
|
+
messages.push({ role: "tool", tool_call_id: c.id, content: result.output });
|
|
562
|
+
yield { type: "done", result: this.pendingRun(runStart, r.halted, messages, toolCalls, turns, usage) };
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
504
566
|
toolCalls.push({ name: c.name, args, output: result.output, isError: result.isError, metadata: result.metadata });
|
|
505
567
|
messages.push({ role: "tool", tool_call_id: c.id, content: result.output });
|
|
506
568
|
yield { type: "tool_result", id: c.id, name: c.name, output: result.output, isError: result.isError };
|
|
@@ -594,7 +656,21 @@ export class Client {
|
|
|
594
656
|
const settled = await Promise.all(uses.map((u) => this.runTool(toolkit, u.name, u.input ?? {}, u.id, turn)));
|
|
595
657
|
const results = [];
|
|
596
658
|
for (let i = 0; i < uses.length; i++) {
|
|
597
|
-
const u = uses[i]
|
|
659
|
+
const u = uses[i];
|
|
660
|
+
let { args, result } = settled[i];
|
|
661
|
+
const req = pendingOf(result);
|
|
662
|
+
if (req) {
|
|
663
|
+
yield { type: "pending", request: req };
|
|
664
|
+
const r = await this.resolvePending(toolkit, u.name, args, req, u.id, turn);
|
|
665
|
+
result = r.result;
|
|
666
|
+
if (r.halted) {
|
|
667
|
+
toolCalls.push({ name: u.name, args, output: result.output, isError: result.isError, metadata: result.metadata });
|
|
668
|
+
results.push({ type: "tool_result", tool_use_id: u.id, content: result.output, is_error: result.isError });
|
|
669
|
+
messages.push({ role: "user", content: results });
|
|
670
|
+
yield { type: "done", result: this.pendingRun(runStart, r.halted, messages, toolCalls, turns, usage) };
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
598
674
|
toolCalls.push({ name: u.name, args, output: result.output, isError: result.isError, metadata: result.metadata });
|
|
599
675
|
results.push({ type: "tool_result", tool_use_id: u.id, content: result.output, is_error: result.isError });
|
|
600
676
|
yield { type: "tool_result", id: u.id, name: u.name, output: result.output, isError: result.isError };
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/mcp.js
CHANGED
|
@@ -32,9 +32,9 @@ export function parseMcpConfig(input) {
|
|
|
32
32
|
if (wrapped !== undefined)
|
|
33
33
|
return wrapped;
|
|
34
34
|
// Bare map of servers — but strip sibling top-level config keys (builtins/
|
|
35
|
-
// agents/a2a) so they are not mistaken for MCP servers when no
|
|
36
|
-
// is present.
|
|
37
|
-
const { builtins: _builtins, agents: _agents, a2a: _a2a, ...servers } = raw;
|
|
35
|
+
// agents/a2a/mcpServer) so they are not mistaken for MCP servers when no
|
|
36
|
+
// wrapper key is present.
|
|
37
|
+
const { builtins: _builtins, agents: _agents, a2a: _a2a, mcpServer: _mcpServer, ...servers } = raw;
|
|
38
38
|
return servers;
|
|
39
39
|
}
|
|
40
40
|
/**
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP — INBOUND: serve a toolkit as an MCP server (SPEC §7C).
|
|
3
|
+
*
|
|
4
|
+
* The inbound mirror of the A2A `serve` (§7B). Where A2A advertises the toolkit's
|
|
5
|
+
* **skills** and fulfils a Task through the whole client loop, MCP advertises the
|
|
6
|
+
* toolkit's **unified tools** (every source — mcp · skill · native · http ·
|
|
7
|
+
* builtin · a2a) and dispatches each `tools/call` straight to `Tool.execute`. The
|
|
8
|
+
* calling MCP client *is* the LLM host, so there is **no client, no Task, and no
|
|
9
|
+
* TaskStore** — just `tools/list` + `tools/call` over the toolkit registry.
|
|
10
|
+
*
|
|
11
|
+
* Transport: **streamable-HTTP**, mounted at `POST /mcp` on the `serve(addr, …)`
|
|
12
|
+
* server alongside the A2A routes (see serve.ts), built on the same
|
|
13
|
+
* `@modelcontextprotocol/sdk` the client side already uses.
|
|
14
|
+
*/
|
|
15
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
16
|
+
import type { Tool, ToolSource } from "./types.js";
|
|
17
|
+
/** Opt-in MCP serve profile — the toolkit is exposed as an MCP server. */
|
|
18
|
+
export interface MCPServeConfig {
|
|
19
|
+
/** Advertised server name (initialize `serverInfo.name`). Default "toolnexus". */
|
|
20
|
+
name?: string;
|
|
21
|
+
/** Advertised server version. Default "0.1.0". */
|
|
22
|
+
version?: string;
|
|
23
|
+
/** Subset of tool names to expose; omit ⇒ all. Unknown names are ignored. */
|
|
24
|
+
tools?: string[];
|
|
25
|
+
}
|
|
26
|
+
/** Surfaced on each inbound `tools/call`. */
|
|
27
|
+
export interface OnCallEvent {
|
|
28
|
+
name: string;
|
|
29
|
+
source: ToolSource;
|
|
30
|
+
ms: number;
|
|
31
|
+
isError: boolean;
|
|
32
|
+
}
|
|
33
|
+
export type OnCall = (ev: OnCallEvent) => void | Promise<void>;
|
|
34
|
+
/**
|
|
35
|
+
* The tools this profile exposes: every toolkit tool, filtered to `cfg.tools`
|
|
36
|
+
* when set (unknown names in the filter are ignored, never an error).
|
|
37
|
+
*/
|
|
38
|
+
export declare function exposedMcpTools(tools: Tool[], cfg?: MCPServeConfig): Tool[];
|
|
39
|
+
/**
|
|
40
|
+
* Build a low-level MCP `Server` exposing `tools` as MCP tools. `tools/list`
|
|
41
|
+
* maps each Tool's name (verbatim — already sanitized at registration),
|
|
42
|
+
* description, and inputSchema; `tools/call` dispatches to `Tool.execute` and
|
|
43
|
+
* maps the `ToolResult` to a `CallToolResult` (`output` → text, `isError`
|
|
44
|
+
* propagates). A fresh server is cheap to build, so the HTTP path makes one per
|
|
45
|
+
* request (stateless).
|
|
46
|
+
*/
|
|
47
|
+
export declare function buildMcpServer(tools: Tool[], cfg?: MCPServeConfig, onCall?: OnCall): Server;
|
package/dist/mcpserve.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP — INBOUND: serve a toolkit as an MCP server (SPEC §7C).
|
|
3
|
+
*
|
|
4
|
+
* The inbound mirror of the A2A `serve` (§7B). Where A2A advertises the toolkit's
|
|
5
|
+
* **skills** and fulfils a Task through the whole client loop, MCP advertises the
|
|
6
|
+
* toolkit's **unified tools** (every source — mcp · skill · native · http ·
|
|
7
|
+
* builtin · a2a) and dispatches each `tools/call` straight to `Tool.execute`. The
|
|
8
|
+
* calling MCP client *is* the LLM host, so there is **no client, no Task, and no
|
|
9
|
+
* TaskStore** — just `tools/list` + `tools/call` over the toolkit registry.
|
|
10
|
+
*
|
|
11
|
+
* Transport: **streamable-HTTP**, mounted at `POST /mcp` on the `serve(addr, …)`
|
|
12
|
+
* server alongside the A2A routes (see serve.ts), built on the same
|
|
13
|
+
* `@modelcontextprotocol/sdk` the client side already uses.
|
|
14
|
+
*/
|
|
15
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
16
|
+
import { ListToolsRequestSchema, CallToolRequestSchema, McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
|
|
17
|
+
const DEFAULT_NAME = "toolnexus";
|
|
18
|
+
const DEFAULT_VERSION = "0.1.0";
|
|
19
|
+
/**
|
|
20
|
+
* The tools this profile exposes: every toolkit tool, filtered to `cfg.tools`
|
|
21
|
+
* when set (unknown names in the filter are ignored, never an error).
|
|
22
|
+
*/
|
|
23
|
+
export function exposedMcpTools(tools, cfg) {
|
|
24
|
+
if (!cfg?.tools)
|
|
25
|
+
return tools;
|
|
26
|
+
const wanted = new Set(cfg.tools);
|
|
27
|
+
return tools.filter((t) => wanted.has(t.name));
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Build a low-level MCP `Server` exposing `tools` as MCP tools. `tools/list`
|
|
31
|
+
* maps each Tool's name (verbatim — already sanitized at registration),
|
|
32
|
+
* description, and inputSchema; `tools/call` dispatches to `Tool.execute` and
|
|
33
|
+
* maps the `ToolResult` to a `CallToolResult` (`output` → text, `isError`
|
|
34
|
+
* propagates). A fresh server is cheap to build, so the HTTP path makes one per
|
|
35
|
+
* request (stateless).
|
|
36
|
+
*/
|
|
37
|
+
export function buildMcpServer(tools, cfg, onCall) {
|
|
38
|
+
const byName = new Map(tools.map((t) => [t.name, t]));
|
|
39
|
+
const server = new Server({ name: cfg?.name ?? DEFAULT_NAME, version: cfg?.version ?? DEFAULT_VERSION }, { capabilities: { tools: {} } });
|
|
40
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
41
|
+
tools: tools.map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema })),
|
|
42
|
+
}));
|
|
43
|
+
server.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
|
|
44
|
+
const tool = byName.get(req.params.name);
|
|
45
|
+
if (!tool)
|
|
46
|
+
throw new McpError(ErrorCode.InvalidParams, `Unknown tool: ${req.params.name}`);
|
|
47
|
+
const started = Date.now();
|
|
48
|
+
// The call is a single synchronous tool invocation honoring the MCP
|
|
49
|
+
// request's cancellation. An execute() throw becomes an isError result —
|
|
50
|
+
// never a crash.
|
|
51
|
+
let result;
|
|
52
|
+
try {
|
|
53
|
+
result = await tool.execute(req.params.arguments ?? {}, { signal: extra.signal });
|
|
54
|
+
}
|
|
55
|
+
catch (e) {
|
|
56
|
+
result = { output: e instanceof Error ? e.message : String(e), isError: true };
|
|
57
|
+
}
|
|
58
|
+
if (onCall) {
|
|
59
|
+
try {
|
|
60
|
+
await onCall({ name: tool.name, source: tool.source, ms: Date.now() - started, isError: result.isError });
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// Host callback errors are isolated.
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return { content: [{ type: "text", text: result.output }], isError: result.isError };
|
|
67
|
+
});
|
|
68
|
+
return server;
|
|
69
|
+
}
|
package/dist/serve.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { type Tool } from "./types.js";
|
|
1
2
|
import type { SkillInfo } from "./skill.js";
|
|
2
3
|
import type { RunResult } from "./client.js";
|
|
3
4
|
import type { A2ATask, A2ATaskState, AgentCard } from "./a2a.js";
|
|
5
|
+
import { type MCPServeConfig, type OnCall } from "./mcpserve.js";
|
|
4
6
|
/** Pluggable Task persistence. The-factory can plug NATS/JetStream the same way. */
|
|
5
7
|
export interface TaskStore {
|
|
6
8
|
get(id: string): Promise<A2ATask | undefined>;
|
|
@@ -71,6 +73,12 @@ export interface StartServerOptions {
|
|
|
71
73
|
* message) keys the conversation so served turns are remembered via the store. */
|
|
72
74
|
runTask: (text: string, contextId?: string) => Promise<RunResult>;
|
|
73
75
|
onTask?: OnTask;
|
|
76
|
+
/** When present, mounts a streamable-HTTP MCP server at `/mcp`; absent ⇒ no MCP route. */
|
|
77
|
+
mcp?: MCPServeConfig;
|
|
78
|
+
/** The toolkit's unified tools, exposed by the MCP profile. */
|
|
79
|
+
mcpTools?: Tool[];
|
|
80
|
+
/** Surfaced on each inbound MCP `tools/call`. */
|
|
81
|
+
onCall?: OnCall;
|
|
74
82
|
}
|
|
75
83
|
/**
|
|
76
84
|
* Start the HTTP server. Delegated to by `Toolkit.serve`. When `a2a` is absent,
|
package/dist/serve.js
CHANGED
|
@@ -18,7 +18,9 @@ import http from "node:http";
|
|
|
18
18
|
import fs from "node:fs";
|
|
19
19
|
import path from "node:path";
|
|
20
20
|
import { randomUUID } from "node:crypto";
|
|
21
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
21
22
|
import { sanitize } from "./types.js";
|
|
23
|
+
import { buildMcpServer, exposedMcpTools } from "./mcpserve.js";
|
|
22
24
|
/** Default in-memory store — Tasks live only for the lifetime of the process. */
|
|
23
25
|
export class InMemoryTaskStore {
|
|
24
26
|
tasks = new Map();
|
|
@@ -111,14 +113,23 @@ function readBody(req) {
|
|
|
111
113
|
req.on("end", () => resolve(body));
|
|
112
114
|
});
|
|
113
115
|
}
|
|
116
|
+
function safeJson(body) {
|
|
117
|
+
try {
|
|
118
|
+
return JSON.parse(body);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
114
124
|
/**
|
|
115
125
|
* Start the HTTP server. Delegated to by `Toolkit.serve`. When `a2a` is absent,
|
|
116
126
|
* the server answers 404 to everything (a minimal base for now).
|
|
117
127
|
*/
|
|
118
128
|
export async function startA2AServer(opts) {
|
|
119
|
-
const { a2a, skills, runTask, onTask } = opts;
|
|
129
|
+
const { a2a, skills, runTask, onTask, mcp, mcpTools, onCall } = opts;
|
|
120
130
|
const [host, portStr] = splitAddr(opts.addr);
|
|
121
131
|
const store = a2a ? resolveStore(a2a.store) : undefined;
|
|
132
|
+
const exposedTools = mcp ? exposedMcpTools(mcpTools ?? [], mcp) : [];
|
|
122
133
|
const server = http.createServer((req, res) => {
|
|
123
134
|
const rpcRes = (id, payload) => {
|
|
124
135
|
res.writeHead(200, { "content-type": "application/json" });
|
|
@@ -126,7 +137,36 @@ export async function startA2AServer(opts) {
|
|
|
126
137
|
};
|
|
127
138
|
const rpcOk = (id, result) => rpcRes(id, { result });
|
|
128
139
|
const rpcErr = (id, code, message) => rpcRes(id, { error: { code, message } });
|
|
129
|
-
// No
|
|
140
|
+
// No profile at all ⇒ no routes.
|
|
141
|
+
if (!store && !mcp) {
|
|
142
|
+
res.writeHead(404);
|
|
143
|
+
res.end("not found");
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
// MCP streamable-HTTP profile (independent of A2A): a fresh server+transport
|
|
147
|
+
// per request (stateless — sessionIdGenerator undefined). Checked first so
|
|
148
|
+
// `/mcp` is never shadowed by the A2A POST handler.
|
|
149
|
+
if (mcp && req.url && (req.url === "/mcp" || req.url.startsWith("/mcp?"))) {
|
|
150
|
+
void (async () => {
|
|
151
|
+
const body = req.method === "POST" ? await readBody(req) : "";
|
|
152
|
+
const parsed = body ? safeJson(body) : undefined;
|
|
153
|
+
const mcpServer = buildMcpServer(exposedTools, mcp, onCall);
|
|
154
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
155
|
+
res.on("close", () => {
|
|
156
|
+
void transport.close();
|
|
157
|
+
void mcpServer.close();
|
|
158
|
+
});
|
|
159
|
+
await mcpServer.connect(transport);
|
|
160
|
+
await transport.handleRequest(req, res, parsed);
|
|
161
|
+
})().catch(() => {
|
|
162
|
+
if (!res.headersSent) {
|
|
163
|
+
res.writeHead(500);
|
|
164
|
+
res.end("mcp error");
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
// No A2A profile ⇒ no A2A routes (an MCP-only server 404s everything else).
|
|
130
170
|
if (!a2a || !store) {
|
|
131
171
|
res.writeHead(404);
|
|
132
172
|
res.end("not found");
|
package/dist/toolkit.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { type Tool, type ToolContext, type ToolResult, type McpStatus } from "./
|
|
|
6
6
|
import { type BuiltinsConfig } from "./builtin.js";
|
|
7
7
|
import { type Agent } from "./a2a.js";
|
|
8
8
|
import { type A2AConfig, type OnTask, type ServeHandle } from "./serve.js";
|
|
9
|
+
import { type MCPServeConfig, type OnCall } from "./mcpserve.js";
|
|
9
10
|
import type { Client } from "./client.js";
|
|
10
11
|
export interface ToolkitOptions {
|
|
11
12
|
mcpConfig?: string | object;
|
|
@@ -21,6 +22,8 @@ export declare class Toolkit {
|
|
|
21
22
|
private readonly skill;
|
|
22
23
|
/** A2A profile read from a top-level `a2a` config block (serve() falls back to this). */
|
|
23
24
|
private readonly a2aConfig;
|
|
25
|
+
/** MCP serve profile read from a top-level `mcpServer` config block. */
|
|
26
|
+
private readonly mcpServerConfig;
|
|
24
27
|
private byName;
|
|
25
28
|
private constructor();
|
|
26
29
|
static create(opts: ToolkitOptions): Promise<Toolkit>;
|
|
@@ -43,9 +46,11 @@ export declare class Toolkit {
|
|
|
43
46
|
* When `a2a` is absent, no A2A routes are mounted. Returns a stoppable handle.
|
|
44
47
|
*/
|
|
45
48
|
serve(addr: string, opts: {
|
|
46
|
-
client
|
|
49
|
+
client?: Client;
|
|
47
50
|
a2a?: A2AConfig;
|
|
48
51
|
onTask?: OnTask;
|
|
52
|
+
mcp?: MCPServeConfig;
|
|
53
|
+
onCall?: OnCall;
|
|
49
54
|
}): Promise<ServeHandle>;
|
|
50
55
|
execute(name: string, args: Record<string, unknown>, ctx?: ToolContext): Promise<ToolResult>;
|
|
51
56
|
/** Markdown skill catalog for the system prompt. */
|
package/dist/toolkit.js
CHANGED
|
@@ -8,13 +8,17 @@ export class Toolkit {
|
|
|
8
8
|
mcp;
|
|
9
9
|
skill;
|
|
10
10
|
a2aConfig;
|
|
11
|
+
mcpServerConfig;
|
|
11
12
|
byName = new Map();
|
|
12
13
|
constructor(mcp, skill, builtins, agents, extraTools,
|
|
13
14
|
/** A2A profile read from a top-level `a2a` config block (serve() falls back to this). */
|
|
14
|
-
a2aConfig
|
|
15
|
+
a2aConfig,
|
|
16
|
+
/** MCP serve profile read from a top-level `mcpServer` config block. */
|
|
17
|
+
mcpServerConfig) {
|
|
15
18
|
this.mcp = mcp;
|
|
16
19
|
this.skill = skill;
|
|
17
20
|
this.a2aConfig = a2aConfig;
|
|
21
|
+
this.mcpServerConfig = mcpServerConfig;
|
|
18
22
|
// Builtins are the lowest-precedence source: a host extraTools entry with
|
|
19
23
|
// the same name shadows a builtin (SPEC §4). Drop shadowed builtins up
|
|
20
24
|
// front, then apply the normal first-wins dedupe for MCP/skill/agents/extras.
|
|
@@ -60,7 +64,14 @@ export class Toolkit {
|
|
|
60
64
|
if (opts.mcpConfig && typeof opts.mcpConfig === "object" && "a2a" in opts.mcpConfig) {
|
|
61
65
|
a2aConfig = opts.mcpConfig.a2a;
|
|
62
66
|
}
|
|
63
|
-
|
|
67
|
+
// MCP inbound profile: a top-level `mcpServer` (singular) block — distinct
|
|
68
|
+
// from the client-side `mcpServers` block. serve()/serveStdio() prefer their
|
|
69
|
+
// inline `mcp` over this.
|
|
70
|
+
let mcpServerConfig;
|
|
71
|
+
if (opts.mcpConfig && typeof opts.mcpConfig === "object" && "mcpServer" in opts.mcpConfig) {
|
|
72
|
+
mcpServerConfig = opts.mcpConfig.mcpServer;
|
|
73
|
+
}
|
|
74
|
+
return new Toolkit(mcp, skill, builtins, agentToolsFlat, opts.extraTools ?? [], a2aConfig, mcpServerConfig);
|
|
64
75
|
}
|
|
65
76
|
tools() {
|
|
66
77
|
return [...this.byName.values()];
|
|
@@ -99,6 +110,7 @@ export class Toolkit {
|
|
|
99
110
|
*/
|
|
100
111
|
serve(addr, opts) {
|
|
101
112
|
const a2a = opts.a2a ?? this.a2aConfig;
|
|
113
|
+
const mcp = opts.mcp ?? this.mcpServerConfig;
|
|
102
114
|
const skills = this.skill ? Object.values(this.skill.skills) : [];
|
|
103
115
|
return startA2AServer({
|
|
104
116
|
addr,
|
|
@@ -108,6 +120,9 @@ export class Toolkit {
|
|
|
108
120
|
? opts.client.ask(text, { toolkit: this, id: contextId })
|
|
109
121
|
: opts.client.run(text, { toolkit: this }),
|
|
110
122
|
onTask: opts.onTask,
|
|
123
|
+
mcp,
|
|
124
|
+
mcpTools: mcp ? this.tools() : undefined,
|
|
125
|
+
onCall: opts.onCall,
|
|
111
126
|
});
|
|
112
127
|
}
|
|
113
128
|
async execute(name, args, ctx) {
|
package/dist/types.d.ts
CHANGED
|
@@ -17,7 +17,35 @@ 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
|
+
/** Producer helper: return a suspension. A ToolResult with `metadata.pending` = a Request. */
|
|
42
|
+
export declare function pending(request: Omit<Request, "id"> & {
|
|
43
|
+
id?: string;
|
|
44
|
+
}): ToolResult;
|
|
45
|
+
/** Sugar for the common case: `kind:"authorization"` at a login URL. */
|
|
46
|
+
export declare function authRequired(url: string, prompt?: string): ToolResult;
|
|
47
|
+
/** Read the suspension off a result, if any. */
|
|
48
|
+
export declare function pendingOf(result: ToolResult): Request | undefined;
|
|
21
49
|
export interface Tool {
|
|
22
50
|
name: string;
|
|
23
51
|
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
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
|
/**
|
|
@@ -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 {
|
|
@@ -458,14 +469,22 @@ export class Client {
|
|
|
458
469
|
const calls = msg.tool_calls ?? []
|
|
459
470
|
if (calls.length === 0) return this.endRun(runStart, msg.content ?? "", messages, toolCalls, turns, usage)
|
|
460
471
|
// execute all tool calls in this turn concurrently (true parallel tool calling)
|
|
472
|
+
let halted: Request | undefined
|
|
461
473
|
const results = await Promise.all(
|
|
462
474
|
calls.map(async (call: any) => {
|
|
463
|
-
|
|
475
|
+
let { args, result } = await this.runTool(toolkit, call.function.name, safeJson(call.function.arguments), call.id, turn)
|
|
476
|
+
const req = pendingOf(result)
|
|
477
|
+
if (req) {
|
|
478
|
+
const r = await this.resolvePending(toolkit, call.function.name, args, req, call.id, turn)
|
|
479
|
+
result = r.result
|
|
480
|
+
if (r.halted) halted = r.halted
|
|
481
|
+
}
|
|
464
482
|
toolCalls.push({ name: call.function.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
|
|
465
483
|
return { role: "tool", tool_call_id: call.id, content: result.output }
|
|
466
484
|
}),
|
|
467
485
|
)
|
|
468
486
|
messages.push(...results)
|
|
487
|
+
if (halted) return this.pendingRun(runStart, halted, messages, toolCalls, turns, usage)
|
|
469
488
|
}
|
|
470
489
|
return this.endRun(runStart, lastText(messages), messages, toolCalls, turns, usage)
|
|
471
490
|
} catch (e) {
|
|
@@ -475,7 +494,35 @@ export class Client {
|
|
|
475
494
|
}
|
|
476
495
|
|
|
477
496
|
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 }
|
|
497
|
+
return { text, messages, toolCalls, toolCallCount: toolCalls.length, turns, usage, model: this.opts.model, status: "done" }
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** §10: a run halted because a tool suspended and no `waitFor` was configured. */
|
|
501
|
+
private pendingRun(runStart: number, request: Request, messages: any[], toolCalls: ToolCallRecord[], turns: number, usage: Usage): RunResult {
|
|
502
|
+
this.emit({ event: "run", model: this.opts.model, turns, toolCalls: toolCalls.length, totalTokens: usage.totalTokens, ms: Date.now() - runStart })
|
|
503
|
+
return { text: request.prompt, messages, toolCalls, toolCallCount: toolCalls.length, turns, usage, model: this.opts.model, status: "pending", pending: request }
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* §10: resolve a suspended tool result. Calls `waitFor(request)`, and on `ok` re-executes the
|
|
508
|
+
* tool once with `Context.answer = answer`. Returns the resolved result, or `{ halted }` when
|
|
509
|
+
* no `waitFor` is configured (the run should stop and surface the request).
|
|
510
|
+
*/
|
|
511
|
+
private async resolvePending(
|
|
512
|
+
toolkit: Toolkit, name: string, args: Record<string, unknown>, request: Request, id: string | undefined, turn: number,
|
|
513
|
+
): Promise<{ result: ToolResult; halted?: Request }> {
|
|
514
|
+
if (!this.opts.waitFor) return { result: { output: request.prompt, isError: true }, halted: request }
|
|
515
|
+
const answer = await this.opts.waitFor(request)
|
|
516
|
+
if (!answer?.ok) return { result: { output: `declined/expired: ${request.prompt}`, isError: true } }
|
|
517
|
+
const t0 = Date.now()
|
|
518
|
+
let result = await toolkit.execute(name, args, { answer })
|
|
519
|
+
if (this.opts.hooks?.afterTool) {
|
|
520
|
+
const ov = await this.opts.hooks.afterTool({ name, args, result, id, turn })
|
|
521
|
+
if (ov?.result) result = ov.result
|
|
522
|
+
}
|
|
523
|
+
this.emit({ event: "tool", tool: name, source: toolkit.get(name)?.source ?? "custom", isError: result.isError, ms: Date.now() - t0 })
|
|
524
|
+
if (pendingOf(result)) result = { output: `unresolved: ${request.prompt}`, isError: true } // never loop forever
|
|
525
|
+
return { result }
|
|
479
526
|
}
|
|
480
527
|
|
|
481
528
|
// ---- Anthropic-style: POST {baseUrl}/messages ----
|
|
@@ -519,14 +566,22 @@ export class Client {
|
|
|
519
566
|
return this.endRun(runStart, text, messages, toolCalls, turns, usage)
|
|
520
567
|
}
|
|
521
568
|
// execute all tool_use blocks in this turn concurrently (true parallel tool calling)
|
|
569
|
+
let halted: Request | undefined
|
|
522
570
|
const results = await Promise.all(
|
|
523
571
|
uses.map(async (use: any) => {
|
|
524
|
-
|
|
572
|
+
let { args, result } = await this.runTool(toolkit, use.name, use.input ?? {}, use.id, turn)
|
|
573
|
+
const req = pendingOf(result)
|
|
574
|
+
if (req) {
|
|
575
|
+
const r = await this.resolvePending(toolkit, use.name, args, req, use.id, turn)
|
|
576
|
+
result = r.result
|
|
577
|
+
if (r.halted) halted = r.halted
|
|
578
|
+
}
|
|
525
579
|
toolCalls.push({ name: use.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
|
|
526
580
|
return { type: "tool_result", tool_use_id: use.id, content: result.output, is_error: result.isError }
|
|
527
581
|
}),
|
|
528
582
|
)
|
|
529
583
|
messages.push({ role: "user", content: results })
|
|
584
|
+
if (halted) return this.pendingRun(runStart, halted, messages, toolCalls, turns, usage)
|
|
530
585
|
}
|
|
531
586
|
return this.endRun(runStart, "", messages, toolCalls, turns, usage)
|
|
532
587
|
} catch (e) {
|
|
@@ -605,7 +660,20 @@ export class Client {
|
|
|
605
660
|
for (const c of calls) yield { type: "tool_call", id: c.id, name: c.name, args: safeJson(c.args) }
|
|
606
661
|
const settled = await Promise.all(calls.map((c) => this.runTool(toolkit, c.name, safeJson(c.args), c.id, turn)))
|
|
607
662
|
for (let i = 0; i < calls.length; i++) {
|
|
608
|
-
const c = calls[i]
|
|
663
|
+
const c = calls[i]
|
|
664
|
+
let { args, result } = settled[i]
|
|
665
|
+
const req = pendingOf(result)
|
|
666
|
+
if (req) {
|
|
667
|
+
yield { type: "pending", request: req } // surface BEFORE waitFor so a channel can push the link
|
|
668
|
+
const r = await this.resolvePending(toolkit, c.name, args, req, c.id, turn)
|
|
669
|
+
result = r.result
|
|
670
|
+
if (r.halted) {
|
|
671
|
+
toolCalls.push({ name: c.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
|
|
672
|
+
messages.push({ role: "tool", tool_call_id: c.id, content: result.output })
|
|
673
|
+
yield { type: "done", result: this.pendingRun(runStart, r.halted, messages, toolCalls, turns, usage) }
|
|
674
|
+
return
|
|
675
|
+
}
|
|
676
|
+
}
|
|
609
677
|
toolCalls.push({ name: c.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
|
|
610
678
|
messages.push({ role: "tool", tool_call_id: c.id, content: result.output })
|
|
611
679
|
yield { type: "tool_result", id: c.id, name: c.name, output: result.output, isError: result.isError }
|
|
@@ -683,7 +751,21 @@ export class Client {
|
|
|
683
751
|
const settled = await Promise.all((uses as any[]).map((u) => this.runTool(toolkit, u.name, u.input ?? {}, u.id, turn)))
|
|
684
752
|
const results: any[] = []
|
|
685
753
|
for (let i = 0; i < uses.length; i++) {
|
|
686
|
-
const u = uses[i] as any
|
|
754
|
+
const u = uses[i] as any
|
|
755
|
+
let { args, result } = settled[i]
|
|
756
|
+
const req = pendingOf(result)
|
|
757
|
+
if (req) {
|
|
758
|
+
yield { type: "pending", request: req }
|
|
759
|
+
const r = await this.resolvePending(toolkit, u.name, args, req, u.id, turn)
|
|
760
|
+
result = r.result
|
|
761
|
+
if (r.halted) {
|
|
762
|
+
toolCalls.push({ name: u.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
|
|
763
|
+
results.push({ type: "tool_result", tool_use_id: u.id, content: result.output, is_error: result.isError })
|
|
764
|
+
messages.push({ role: "user", content: results })
|
|
765
|
+
yield { type: "done", result: this.pendingRun(runStart, r.halted, messages, toolCalls, turns, usage) }
|
|
766
|
+
return
|
|
767
|
+
}
|
|
768
|
+
}
|
|
687
769
|
toolCalls.push({ name: u.name, args, output: result.output, isError: result.isError, metadata: result.metadata })
|
|
688
770
|
results.push({ type: "tool_result", tool_use_id: u.id, content: result.output, is_error: result.isError })
|
|
689
771
|
yield { type: "tool_result", id: u.id, name: u.name, output: result.output, isError: result.isError }
|
package/src/index.ts
CHANGED
package/src/mcp.ts
CHANGED
|
@@ -55,9 +55,9 @@ export function parseMcpConfig(input: string | object): McpConfig {
|
|
|
55
55
|
const wrapped = raw.mcpServers ?? raw.servers ?? raw.mcp
|
|
56
56
|
if (wrapped !== undefined) return wrapped as McpConfig
|
|
57
57
|
// Bare map of servers — but strip sibling top-level config keys (builtins/
|
|
58
|
-
// agents/a2a) so they are not mistaken for MCP servers when no
|
|
59
|
-
// is present.
|
|
60
|
-
const { builtins: _builtins, agents: _agents, a2a: _a2a, ...servers } = raw
|
|
58
|
+
// agents/a2a/mcpServer) so they are not mistaken for MCP servers when no
|
|
59
|
+
// wrapper key is present.
|
|
60
|
+
const { builtins: _builtins, agents: _agents, a2a: _a2a, mcpServer: _mcpServer, ...servers } = raw
|
|
61
61
|
return servers as McpConfig
|
|
62
62
|
}
|
|
63
63
|
|
package/src/mcpserve.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP — INBOUND: serve a toolkit as an MCP server (SPEC §7C).
|
|
3
|
+
*
|
|
4
|
+
* The inbound mirror of the A2A `serve` (§7B). Where A2A advertises the toolkit's
|
|
5
|
+
* **skills** and fulfils a Task through the whole client loop, MCP advertises the
|
|
6
|
+
* toolkit's **unified tools** (every source — mcp · skill · native · http ·
|
|
7
|
+
* builtin · a2a) and dispatches each `tools/call` straight to `Tool.execute`. The
|
|
8
|
+
* calling MCP client *is* the LLM host, so there is **no client, no Task, and no
|
|
9
|
+
* TaskStore** — just `tools/list` + `tools/call` over the toolkit registry.
|
|
10
|
+
*
|
|
11
|
+
* Transport: **streamable-HTTP**, mounted at `POST /mcp` on the `serve(addr, …)`
|
|
12
|
+
* server alongside the A2A routes (see serve.ts), built on the same
|
|
13
|
+
* `@modelcontextprotocol/sdk` the client side already uses.
|
|
14
|
+
*/
|
|
15
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
|
|
16
|
+
import { ListToolsRequestSchema, CallToolRequestSchema, McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"
|
|
17
|
+
import type { Tool, ToolSource } from "./types.js"
|
|
18
|
+
|
|
19
|
+
const DEFAULT_NAME = "toolnexus"
|
|
20
|
+
const DEFAULT_VERSION = "0.1.0"
|
|
21
|
+
|
|
22
|
+
/** Opt-in MCP serve profile — the toolkit is exposed as an MCP server. */
|
|
23
|
+
export interface MCPServeConfig {
|
|
24
|
+
/** Advertised server name (initialize `serverInfo.name`). Default "toolnexus". */
|
|
25
|
+
name?: string
|
|
26
|
+
/** Advertised server version. Default "0.1.0". */
|
|
27
|
+
version?: string
|
|
28
|
+
/** Subset of tool names to expose; omit ⇒ all. Unknown names are ignored. */
|
|
29
|
+
tools?: string[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Surfaced on each inbound `tools/call`. */
|
|
33
|
+
export interface OnCallEvent {
|
|
34
|
+
name: string
|
|
35
|
+
source: ToolSource
|
|
36
|
+
ms: number
|
|
37
|
+
isError: boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type OnCall = (ev: OnCallEvent) => void | Promise<void>
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The tools this profile exposes: every toolkit tool, filtered to `cfg.tools`
|
|
44
|
+
* when set (unknown names in the filter are ignored, never an error).
|
|
45
|
+
*/
|
|
46
|
+
export function exposedMcpTools(tools: Tool[], cfg?: MCPServeConfig): Tool[] {
|
|
47
|
+
if (!cfg?.tools) return tools
|
|
48
|
+
const wanted = new Set(cfg.tools)
|
|
49
|
+
return tools.filter((t) => wanted.has(t.name))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Build a low-level MCP `Server` exposing `tools` as MCP tools. `tools/list`
|
|
54
|
+
* maps each Tool's name (verbatim — already sanitized at registration),
|
|
55
|
+
* description, and inputSchema; `tools/call` dispatches to `Tool.execute` and
|
|
56
|
+
* maps the `ToolResult` to a `CallToolResult` (`output` → text, `isError`
|
|
57
|
+
* propagates). A fresh server is cheap to build, so the HTTP path makes one per
|
|
58
|
+
* request (stateless).
|
|
59
|
+
*/
|
|
60
|
+
export function buildMcpServer(tools: Tool[], cfg?: MCPServeConfig, onCall?: OnCall): Server {
|
|
61
|
+
const byName = new Map(tools.map((t) => [t.name, t]))
|
|
62
|
+
const server = new Server(
|
|
63
|
+
{ name: cfg?.name ?? DEFAULT_NAME, version: cfg?.version ?? DEFAULT_VERSION },
|
|
64
|
+
{ capabilities: { tools: {} } },
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
68
|
+
tools: tools.map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema })),
|
|
69
|
+
}))
|
|
70
|
+
|
|
71
|
+
server.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
|
|
72
|
+
const tool = byName.get(req.params.name)
|
|
73
|
+
if (!tool) throw new McpError(ErrorCode.InvalidParams, `Unknown tool: ${req.params.name}`)
|
|
74
|
+
const started = Date.now()
|
|
75
|
+
// The call is a single synchronous tool invocation honoring the MCP
|
|
76
|
+
// request's cancellation. An execute() throw becomes an isError result —
|
|
77
|
+
// never a crash.
|
|
78
|
+
let result
|
|
79
|
+
try {
|
|
80
|
+
result = await tool.execute(req.params.arguments ?? {}, { signal: extra.signal })
|
|
81
|
+
} catch (e) {
|
|
82
|
+
result = { output: e instanceof Error ? e.message : String(e), isError: true }
|
|
83
|
+
}
|
|
84
|
+
if (onCall) {
|
|
85
|
+
try {
|
|
86
|
+
await onCall({ name: tool.name, source: tool.source, ms: Date.now() - started, isError: result.isError })
|
|
87
|
+
} catch {
|
|
88
|
+
// Host callback errors are isolated.
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { content: [{ type: "text", text: result.output }], isError: result.isError }
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
return server
|
|
95
|
+
}
|
package/src/serve.ts
CHANGED
|
@@ -18,10 +18,12 @@ import http from "node:http"
|
|
|
18
18
|
import fs from "node:fs"
|
|
19
19
|
import path from "node:path"
|
|
20
20
|
import { randomUUID } from "node:crypto"
|
|
21
|
-
import {
|
|
21
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"
|
|
22
|
+
import { sanitize, type Tool } from "./types.js"
|
|
22
23
|
import type { SkillInfo } from "./skill.js"
|
|
23
24
|
import type { RunResult } from "./client.js"
|
|
24
25
|
import type { A2AArtifact, A2ATask, A2ATaskState, AgentCard } from "./a2a.js"
|
|
26
|
+
import { buildMcpServer, exposedMcpTools, type MCPServeConfig, type OnCall } from "./mcpserve.js"
|
|
25
27
|
|
|
26
28
|
// ---------------------------------------------------------------------------
|
|
27
29
|
// TaskStore adapter — pluggable persistence for served Tasks.
|
|
@@ -162,6 +164,12 @@ export interface StartServerOptions {
|
|
|
162
164
|
* message) keys the conversation so served turns are remembered via the store. */
|
|
163
165
|
runTask: (text: string, contextId?: string) => Promise<RunResult>
|
|
164
166
|
onTask?: OnTask
|
|
167
|
+
/** When present, mounts a streamable-HTTP MCP server at `/mcp`; absent ⇒ no MCP route. */
|
|
168
|
+
mcp?: MCPServeConfig
|
|
169
|
+
/** The toolkit's unified tools, exposed by the MCP profile. */
|
|
170
|
+
mcpTools?: Tool[]
|
|
171
|
+
/** Surfaced on each inbound MCP `tools/call`. */
|
|
172
|
+
onCall?: OnCall
|
|
165
173
|
}
|
|
166
174
|
|
|
167
175
|
/** Extract the concatenated text of a JSON-RPC `SendMessage` message's parts. */
|
|
@@ -182,14 +190,23 @@ function readBody(req: http.IncomingMessage): Promise<string> {
|
|
|
182
190
|
})
|
|
183
191
|
}
|
|
184
192
|
|
|
193
|
+
function safeJson(body: string): unknown {
|
|
194
|
+
try {
|
|
195
|
+
return JSON.parse(body)
|
|
196
|
+
} catch {
|
|
197
|
+
return undefined
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
185
201
|
/**
|
|
186
202
|
* Start the HTTP server. Delegated to by `Toolkit.serve`. When `a2a` is absent,
|
|
187
203
|
* the server answers 404 to everything (a minimal base for now).
|
|
188
204
|
*/
|
|
189
205
|
export async function startA2AServer(opts: StartServerOptions): Promise<ServeHandle> {
|
|
190
|
-
const { a2a, skills, runTask, onTask } = opts
|
|
206
|
+
const { a2a, skills, runTask, onTask, mcp, mcpTools, onCall } = opts
|
|
191
207
|
const [host, portStr] = splitAddr(opts.addr)
|
|
192
208
|
const store = a2a ? resolveStore(a2a.store) : undefined
|
|
209
|
+
const exposedTools = mcp ? exposedMcpTools(mcpTools ?? [], mcp) : []
|
|
193
210
|
|
|
194
211
|
const server = http.createServer((req, res) => {
|
|
195
212
|
const rpcRes = (id: unknown, payload: object) => {
|
|
@@ -199,7 +216,38 @@ export async function startA2AServer(opts: StartServerOptions): Promise<ServeHan
|
|
|
199
216
|
const rpcOk = (id: unknown, result: unknown) => rpcRes(id, { result })
|
|
200
217
|
const rpcErr = (id: unknown, code: number, message: string) => rpcRes(id, { error: { code, message } })
|
|
201
218
|
|
|
202
|
-
// No
|
|
219
|
+
// No profile at all ⇒ no routes.
|
|
220
|
+
if (!store && !mcp) {
|
|
221
|
+
res.writeHead(404)
|
|
222
|
+
res.end("not found")
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// MCP streamable-HTTP profile (independent of A2A): a fresh server+transport
|
|
227
|
+
// per request (stateless — sessionIdGenerator undefined). Checked first so
|
|
228
|
+
// `/mcp` is never shadowed by the A2A POST handler.
|
|
229
|
+
if (mcp && req.url && (req.url === "/mcp" || req.url.startsWith("/mcp?"))) {
|
|
230
|
+
void (async () => {
|
|
231
|
+
const body = req.method === "POST" ? await readBody(req) : ""
|
|
232
|
+
const parsed = body ? safeJson(body) : undefined
|
|
233
|
+
const mcpServer = buildMcpServer(exposedTools, mcp, onCall)
|
|
234
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })
|
|
235
|
+
res.on("close", () => {
|
|
236
|
+
void transport.close()
|
|
237
|
+
void mcpServer.close()
|
|
238
|
+
})
|
|
239
|
+
await mcpServer.connect(transport)
|
|
240
|
+
await transport.handleRequest(req, res, parsed)
|
|
241
|
+
})().catch(() => {
|
|
242
|
+
if (!res.headersSent) {
|
|
243
|
+
res.writeHead(500)
|
|
244
|
+
res.end("mcp error")
|
|
245
|
+
}
|
|
246
|
+
})
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// No A2A profile ⇒ no A2A routes (an MCP-only server 404s everything else).
|
|
203
251
|
if (!a2a || !store) {
|
|
204
252
|
res.writeHead(404)
|
|
205
253
|
res.end("not found")
|
package/src/toolkit.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { loadSkills, type SkillSource } from "./skill.js"
|
|
|
8
8
|
import { selectBuiltins, type BuiltinsConfig } from "./builtin.js"
|
|
9
9
|
import { agentTools, parseAgentsConfig, type Agent } from "./a2a.js"
|
|
10
10
|
import { startA2AServer, type A2AConfig, type OnTask, type ServeHandle } from "./serve.js"
|
|
11
|
+
import { type MCPServeConfig, type OnCall } from "./mcpserve.js"
|
|
11
12
|
import { toAnthropic, toGemini, toOpenAI } from "./adapters.js"
|
|
12
13
|
import type { Client } from "./client.js"
|
|
13
14
|
|
|
@@ -31,6 +32,8 @@ export class Toolkit {
|
|
|
31
32
|
extraTools: Tool[],
|
|
32
33
|
/** A2A profile read from a top-level `a2a` config block (serve() falls back to this). */
|
|
33
34
|
private readonly a2aConfig: A2AConfig | undefined,
|
|
35
|
+
/** MCP serve profile read from a top-level `mcpServer` config block. */
|
|
36
|
+
private readonly mcpServerConfig: MCPServeConfig | undefined,
|
|
34
37
|
) {
|
|
35
38
|
// Builtins are the lowest-precedence source: a host extraTools entry with
|
|
36
39
|
// the same name shadows a builtin (SPEC §4). Drop shadowed builtins up
|
|
@@ -87,7 +90,15 @@ export class Toolkit {
|
|
|
87
90
|
a2aConfig = (opts.mcpConfig as { a2a?: A2AConfig }).a2a
|
|
88
91
|
}
|
|
89
92
|
|
|
90
|
-
|
|
93
|
+
// MCP inbound profile: a top-level `mcpServer` (singular) block — distinct
|
|
94
|
+
// from the client-side `mcpServers` block. serve()/serveStdio() prefer their
|
|
95
|
+
// inline `mcp` over this.
|
|
96
|
+
let mcpServerConfig: MCPServeConfig | undefined
|
|
97
|
+
if (opts.mcpConfig && typeof opts.mcpConfig === "object" && "mcpServer" in opts.mcpConfig) {
|
|
98
|
+
mcpServerConfig = (opts.mcpConfig as { mcpServer?: MCPServeConfig }).mcpServer
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return new Toolkit(mcp, skill, builtins, agentToolsFlat, opts.extraTools ?? [], a2aConfig, mcpServerConfig)
|
|
91
102
|
}
|
|
92
103
|
|
|
93
104
|
tools(): Tool[] {
|
|
@@ -129,8 +140,12 @@ export class Toolkit {
|
|
|
129
140
|
* peer's turns are remembered through the client's ConversationStore.
|
|
130
141
|
* When `a2a` is absent, no A2A routes are mounted. Returns a stoppable handle.
|
|
131
142
|
*/
|
|
132
|
-
serve(
|
|
143
|
+
serve(
|
|
144
|
+
addr: string,
|
|
145
|
+
opts: { client?: Client; a2a?: A2AConfig; onTask?: OnTask; mcp?: MCPServeConfig; onCall?: OnCall },
|
|
146
|
+
): Promise<ServeHandle> {
|
|
133
147
|
const a2a = opts.a2a ?? this.a2aConfig
|
|
148
|
+
const mcp = opts.mcp ?? this.mcpServerConfig
|
|
134
149
|
const skills = this.skill ? Object.values(this.skill.skills) : []
|
|
135
150
|
return startA2AServer({
|
|
136
151
|
addr,
|
|
@@ -138,9 +153,12 @@ export class Toolkit {
|
|
|
138
153
|
skills,
|
|
139
154
|
runTask: (text, contextId) =>
|
|
140
155
|
contextId
|
|
141
|
-
? opts.client
|
|
142
|
-
: opts.client
|
|
156
|
+
? opts.client!.ask(text, { toolkit: this, id: contextId })
|
|
157
|
+
: opts.client!.run(text, { toolkit: this }),
|
|
143
158
|
onTask: opts.onTask,
|
|
159
|
+
mcp,
|
|
160
|
+
mcpTools: mcp ? this.tools() : undefined,
|
|
161
|
+
onCall: opts.onCall,
|
|
144
162
|
})
|
|
145
163
|
}
|
|
146
164
|
|
package/src/types.ts
CHANGED
|
@@ -21,6 +21,50 @@ 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
|
+
|
|
48
|
+
let _pendingSeq = 0
|
|
49
|
+
/** Producer helper: return a suspension. A ToolResult with `metadata.pending` = a Request. */
|
|
50
|
+
export function pending(request: Omit<Request, "id"> & { id?: string }): ToolResult {
|
|
51
|
+
const id = request.id ?? `pnd-${Date.now().toString(36)}-${++_pendingSeq}`
|
|
52
|
+
const req: Request = { ...request, id }
|
|
53
|
+
return {
|
|
54
|
+
output: req.prompt + (req.url ? `\n${req.url}` : ""),
|
|
55
|
+
isError: true,
|
|
56
|
+
metadata: { pending: req },
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Sugar for the common case: `kind:"authorization"` at a login URL. */
|
|
61
|
+
export function authRequired(url: string, prompt = "Authorization required to continue"): ToolResult {
|
|
62
|
+
return pending({ kind: "authorization", prompt, url })
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Read the suspension off a result, if any. */
|
|
66
|
+
export function pendingOf(result: ToolResult): Request | undefined {
|
|
67
|
+
return (result.metadata as { pending?: Request } | undefined)?.pending
|
|
24
68
|
}
|
|
25
69
|
|
|
26
70
|
export interface Tool {
|