toolnexus 0.8.0 → 0.9.3
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/client.d.ts +29 -0
- package/dist/client.js +59 -5
- package/dist/mcp.d.ts +30 -2
- package/dist/mcp.js +132 -32
- package/dist/toolkit.d.ts +3 -0
- package/dist/toolkit.js +1 -1
- package/package.json +1 -1
- package/src/client.ts +72 -5
- package/src/mcp.ts +157 -35
- package/src/toolkit.ts +4 -1
package/dist/client.d.ts
CHANGED
|
@@ -32,6 +32,18 @@ export interface ClientOptions {
|
|
|
32
32
|
* (open a browser, message a channel, watch a file, forward to another agent). Omit for a durable
|
|
33
33
|
* host: run() does not hang — it returns { status:"pending", pending:request } to resume later. */
|
|
34
34
|
waitFor?: (request: Request) => Promise<Answer>;
|
|
35
|
+
/** §8 Gap 1. Extra top-level keys shallow-merged into EVERY LLM body after the client builds
|
|
36
|
+
* its own — a requestParams key WINS on collision (e.g. max_tokens overrides the anthropic
|
|
37
|
+
* default). `messages`/`tools`/`stream` are forbidden here (stripped + warned) — rewrite
|
|
38
|
+
* messages via bodyTransform. Applied on all four paths. Omit ⇒ body byte-identical to today. */
|
|
39
|
+
requestParams?: Record<string, unknown>;
|
|
40
|
+
/** §8 Gap 1. Receives the fully assembled body (after requestParams merge) just before marshal
|
|
41
|
+
* and returns the body to send — the escape hatch for provider-specific rewriting (e.g. tool-role
|
|
42
|
+
* message preprocessing) without a proxy. Returning a falsy value ⇒ unchanged. Runs on every call. */
|
|
43
|
+
bodyTransform?: (body: Record<string, any>) => Record<string, any> | undefined | void;
|
|
44
|
+
/** §8 Gap 2. HTTP transport for LLM requests (retries included). Omit ⇒ global fetch. Scope is the
|
|
45
|
+
* LLM path only; MCP transports use the SDK's own client. */
|
|
46
|
+
fetch?: typeof fetch;
|
|
35
47
|
}
|
|
36
48
|
/**
|
|
37
49
|
* Semantic observability events (§8). NOT counter/histogram primitives — readable records the
|
|
@@ -191,6 +203,23 @@ export declare class Client {
|
|
|
191
203
|
/** Cumulative Prometheus registry fed by the same events `onMetric` sees. */
|
|
192
204
|
private readonly registry;
|
|
193
205
|
constructor(opts: ClientOptions);
|
|
206
|
+
/**
|
|
207
|
+
* §8 Gap 4. The client's conversation store — the exact instance passed in `opts.store`, else
|
|
208
|
+
* the default in-memory store the client created. Read (`get`) and write (`save`) it directly to
|
|
209
|
+
* share state with `ask`; no consumer needs a shadow copy.
|
|
210
|
+
*/
|
|
211
|
+
conversationStore(): ConversationStore;
|
|
212
|
+
/**
|
|
213
|
+
* §8 Gap 1. Apply the request-shaping contract to an assembled body: strip forbidden keys from
|
|
214
|
+
* requestParams, shallow-merge requestParams (caller wins), then run bodyTransform last. Called at
|
|
215
|
+
* every LLM body-build site so the ordering contract holds identically on all four paths.
|
|
216
|
+
*/
|
|
217
|
+
private finalizeBody;
|
|
218
|
+
/** §8 Gap 5. OpenAI-style body; omits tools/tool_choice when the tool list is empty. Key order
|
|
219
|
+
* matches the pre-change body so a no-options call is byte-identical. */
|
|
220
|
+
private openaiBody;
|
|
221
|
+
/** §8 Gap 5. Anthropic-style body; omits tools when the tool list is empty. */
|
|
222
|
+
private anthropicBody;
|
|
194
223
|
/** Prometheus text exposition of cumulative metrics (§8). Always valid, empty-but-valid before activity. */
|
|
195
224
|
metrics(): string;
|
|
196
225
|
/** Feed a semantic metric event to the built-in registry and the optional onMetric sink. */
|
package/dist/client.js
CHANGED
|
@@ -150,6 +150,60 @@ export class Client {
|
|
|
150
150
|
this.opts = opts;
|
|
151
151
|
this.store = opts.store ?? new InMemoryConversationStore();
|
|
152
152
|
}
|
|
153
|
+
/**
|
|
154
|
+
* §8 Gap 4. The client's conversation store — the exact instance passed in `opts.store`, else
|
|
155
|
+
* the default in-memory store the client created. Read (`get`) and write (`save`) it directly to
|
|
156
|
+
* share state with `ask`; no consumer needs a shadow copy.
|
|
157
|
+
*/
|
|
158
|
+
conversationStore() {
|
|
159
|
+
return this.store;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* §8 Gap 1. Apply the request-shaping contract to an assembled body: strip forbidden keys from
|
|
163
|
+
* requestParams, shallow-merge requestParams (caller wins), then run bodyTransform last. Called at
|
|
164
|
+
* every LLM body-build site so the ordering contract holds identically on all four paths.
|
|
165
|
+
*/
|
|
166
|
+
finalizeBody(body) {
|
|
167
|
+
const rp = this.opts.requestParams;
|
|
168
|
+
if (rp) {
|
|
169
|
+
for (const [k, v] of Object.entries(rp)) {
|
|
170
|
+
if (k === "messages" || k === "tools" || k === "stream") {
|
|
171
|
+
console.warn(`[toolnexus] requestParams key "${k}" is not allowed — ignored (use bodyTransform)`);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
body[k] = v;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (this.opts.bodyTransform) {
|
|
178
|
+
const out = this.opts.bodyTransform(body);
|
|
179
|
+
if (out)
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
return body;
|
|
183
|
+
}
|
|
184
|
+
/** §8 Gap 5. OpenAI-style body; omits tools/tool_choice when the tool list is empty. Key order
|
|
185
|
+
* matches the pre-change body so a no-options call is byte-identical. */
|
|
186
|
+
openaiBody(messages, tools, stream) {
|
|
187
|
+
const b = { model: this.opts.model, messages };
|
|
188
|
+
if (tools.length) {
|
|
189
|
+
b.tools = tools;
|
|
190
|
+
b.tool_choice = "auto";
|
|
191
|
+
}
|
|
192
|
+
if (stream) {
|
|
193
|
+
b.stream = true;
|
|
194
|
+
b.stream_options = { include_usage: true };
|
|
195
|
+
}
|
|
196
|
+
return this.finalizeBody(b);
|
|
197
|
+
}
|
|
198
|
+
/** §8 Gap 5. Anthropic-style body; omits tools when the tool list is empty. */
|
|
199
|
+
anthropicBody(system, messages, tools, stream) {
|
|
200
|
+
const b = { model: this.opts.model, max_tokens: 4096, system, messages };
|
|
201
|
+
if (tools.length)
|
|
202
|
+
b.tools = tools;
|
|
203
|
+
if (stream)
|
|
204
|
+
b.stream = true;
|
|
205
|
+
return this.finalizeBody(b);
|
|
206
|
+
}
|
|
153
207
|
/** Prometheus text exposition of cumulative metrics (§8). Always valid, empty-but-valid before activity. */
|
|
154
208
|
metrics() {
|
|
155
209
|
return this.registry.render();
|
|
@@ -244,7 +298,7 @@ export class Client {
|
|
|
244
298
|
let lastErr;
|
|
245
299
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
246
300
|
try {
|
|
247
|
-
const res = await fetch(url, { ...init, signal });
|
|
301
|
+
const res = await (this.opts.fetch ?? fetch)(url, { ...init, signal });
|
|
248
302
|
if (res.ok || !RETRYABLE.has(res.status) || attempt === retries)
|
|
249
303
|
return res;
|
|
250
304
|
const ra = Number(res.headers.get("retry-after"));
|
|
@@ -341,7 +395,7 @@ export class Client {
|
|
|
341
395
|
const data = await this.llmCallJson(`${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`, {
|
|
342
396
|
method: "POST",
|
|
343
397
|
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", ...this.opts.headers },
|
|
344
|
-
body: JSON.stringify(
|
|
398
|
+
body: JSON.stringify(this.openaiBody(messages, tools, false)),
|
|
345
399
|
}, signal, "openai");
|
|
346
400
|
addUsage(usage, data.usage, "openai");
|
|
347
401
|
if (this.opts.hooks?.afterLLM)
|
|
@@ -442,7 +496,7 @@ export class Client {
|
|
|
442
496
|
"Content-Type": "application/json",
|
|
443
497
|
...this.opts.headers,
|
|
444
498
|
},
|
|
445
|
-
body: JSON.stringify(
|
|
499
|
+
body: JSON.stringify(this.anthropicBody(system, messages, tools, false)),
|
|
446
500
|
}, signal, "anthropic");
|
|
447
501
|
addUsage(usage, data.usage, "anthropic");
|
|
448
502
|
if (this.opts.hooks?.afterLLM)
|
|
@@ -523,7 +577,7 @@ export class Client {
|
|
|
523
577
|
const res = await this.llmFetch(`${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`, {
|
|
524
578
|
method: "POST",
|
|
525
579
|
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", ...this.opts.headers },
|
|
526
|
-
body: JSON.stringify(
|
|
580
|
+
body: JSON.stringify(this.openaiBody(messages, tools, true)),
|
|
527
581
|
}, signal);
|
|
528
582
|
if (!res.ok || !res.body)
|
|
529
583
|
throw new Error(`LLM ${res.status}: ${await res.text()}`);
|
|
@@ -630,7 +684,7 @@ export class Client {
|
|
|
630
684
|
const res = await this.llmFetch(endpoint, {
|
|
631
685
|
method: "POST",
|
|
632
686
|
headers: { "x-api-key": key, "anthropic-version": "2023-06-01", "Content-Type": "application/json", ...this.opts.headers },
|
|
633
|
-
body: JSON.stringify(
|
|
687
|
+
body: JSON.stringify(this.anthropicBody(system, messages, tools, true)),
|
|
634
688
|
}, signal);
|
|
635
689
|
if (!res.ok || !res.body)
|
|
636
690
|
throw new Error(`LLM ${res.status}: ${await res.text()}`);
|
package/dist/mcp.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ElicitRequest, ElicitResult } from "@modelcontextprotocol/sdk/types.js";
|
|
2
|
-
import { type Tool, type McpStatus, type Request, type Answer } from "./types.js";
|
|
2
|
+
import { type JSONSchema, type Tool, type McpStatus, type Request, type Answer } from "./types.js";
|
|
3
3
|
/** Map an MCP `elicitation/create` request onto a §10 `Request`. */
|
|
4
4
|
export declare function elicitationToRequest(params: ElicitRequest["params"]): Request;
|
|
5
5
|
/** Map a resolved §10 `Answer` back onto an MCP `ElicitResult`. ok→accept; declined→decline; else→cancel. */
|
|
@@ -13,6 +13,10 @@ export interface LocalServer {
|
|
|
13
13
|
enabled?: boolean;
|
|
14
14
|
disabled?: boolean;
|
|
15
15
|
timeout?: number;
|
|
16
|
+
/** §2 Gap 7. Per-server tool allowlist keyed on ORIGINAL tool name; same semantics as the
|
|
17
|
+
* builtins/skills filter (nil/empty ⇒ all; ≥1 true ⇒ allowlist; only-false ⇒ drop-list; unknown
|
|
18
|
+
* ⇒ ignore+warn). Applied to the listed defs before sanitize/prefix. */
|
|
19
|
+
tools?: Record<string, boolean>;
|
|
16
20
|
}
|
|
17
21
|
export interface RemoteServer {
|
|
18
22
|
type?: "remote";
|
|
@@ -21,6 +25,8 @@ export interface RemoteServer {
|
|
|
21
25
|
enabled?: boolean;
|
|
22
26
|
disabled?: boolean;
|
|
23
27
|
timeout?: number;
|
|
28
|
+
/** §2 Gap 7. Per-server tool allowlist (see LocalServer.tools). */
|
|
29
|
+
tools?: Record<string, boolean>;
|
|
24
30
|
}
|
|
25
31
|
export type ServerConfig = LocalServer | RemoteServer;
|
|
26
32
|
export type McpConfig = Record<string, ServerConfig>;
|
|
@@ -36,7 +42,29 @@ export interface McpSource {
|
|
|
36
42
|
status: Record<string, McpStatus>;
|
|
37
43
|
close(): Promise<void>;
|
|
38
44
|
}
|
|
39
|
-
/** Connect to every enabled server, list + convert tools. Failures are isolated.
|
|
45
|
+
/** Connect to every enabled server, list + convert tools. Failures are isolated.
|
|
46
|
+
* §2 Gap 3: an optional `signal` bounds each connect/list by the server timeout and aborts the
|
|
47
|
+
* whole load promptly on parent cancellation. */
|
|
40
48
|
export declare function loadMcp(input: string | object, opts?: {
|
|
41
49
|
waitFor?: (request: Request) => Promise<Answer>;
|
|
50
|
+
signal?: AbortSignal;
|
|
42
51
|
}): Promise<McpSource>;
|
|
52
|
+
/** §2 Gap 6. One listed tool definition (original, unprefixed name). */
|
|
53
|
+
export interface ToolInfo {
|
|
54
|
+
name: string;
|
|
55
|
+
description: string;
|
|
56
|
+
inputSchema: JSONSchema;
|
|
57
|
+
}
|
|
58
|
+
/** §2 Gap 6. Result of a list-only pass over an MCP config. */
|
|
59
|
+
export interface McpInventory {
|
|
60
|
+
/** server → its full listed tool defs, UNFILTERED by ServerConfig.tools (authors the allowlist). */
|
|
61
|
+
tools: Record<string, ToolInfo[]>;
|
|
62
|
+
status: Record<string, McpStatus>;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* §2 Gap 6. Connect to every enabled server, list tool definitions, and DISCONNECT before
|
|
66
|
+
* returning — no toolkit, no execute wiring, nothing left running. Failure isolation as in loadMcp.
|
|
67
|
+
*/
|
|
68
|
+
export declare function listMcpTools(input: string | object, opts?: {
|
|
69
|
+
signal?: AbortSignal;
|
|
70
|
+
}): Promise<McpInventory>;
|
package/dist/mcp.js
CHANGED
|
@@ -99,6 +99,42 @@ function joinTextContent(content) {
|
|
|
99
99
|
.flatMap((item) => (isTextContent(item) ? [item.text] : []))
|
|
100
100
|
.join("\n");
|
|
101
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* §2 Gap 7. Apply a per-server tool allowlist to the listed defs. Same semantics as the builtins
|
|
104
|
+
* and skills filters: nil/empty ⇒ all; ≥1 true ⇒ allowlist (only true names); only-false ⇒ drop-list
|
|
105
|
+
* over all-on; unknown names ignored + warned once. Keyed on ORIGINAL tool name.
|
|
106
|
+
*/
|
|
107
|
+
function applyToolsFilter(server, defs, filter) {
|
|
108
|
+
if (!filter)
|
|
109
|
+
return defs;
|
|
110
|
+
const keys = Object.keys(filter);
|
|
111
|
+
if (keys.length === 0)
|
|
112
|
+
return defs;
|
|
113
|
+
const hasTrue = keys.some((k) => filter[k] === true);
|
|
114
|
+
const present = new Set(defs.map((d) => d.name));
|
|
115
|
+
for (const k of keys) {
|
|
116
|
+
if (!present.has(k))
|
|
117
|
+
console.warn(`[toolnexus] server "${server}" tools filter name "${k}" matched no tool`);
|
|
118
|
+
}
|
|
119
|
+
return defs.filter((d) => (hasTrue ? filter[d.name] === true : filter[d.name] !== false));
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* §2 Gap 3. Race a promise against a per-server timeout and the caller's abort signal, so a hung
|
|
123
|
+
* connect/list is bounded (the SSE fallback included) and a parent cancellation aborts promptly.
|
|
124
|
+
*/
|
|
125
|
+
function raceTimeout(p, ms, signal) {
|
|
126
|
+
return new Promise((resolve, reject) => {
|
|
127
|
+
const onAbort = () => { cleanup(); reject(signal?.reason ?? new Error("aborted")); };
|
|
128
|
+
const t = setTimeout(() => { cleanup(); reject(new Error("mcp connect/list timeout")); }, ms);
|
|
129
|
+
const cleanup = () => { clearTimeout(t); signal?.removeEventListener("abort", onAbort); };
|
|
130
|
+
if (signal) {
|
|
131
|
+
if (signal.aborted)
|
|
132
|
+
return onAbort();
|
|
133
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
134
|
+
}
|
|
135
|
+
p.then((v) => { cleanup(); resolve(v); }, (e) => { cleanup(); reject(e); });
|
|
136
|
+
});
|
|
137
|
+
}
|
|
102
138
|
async function paginateTools(client, timeout) {
|
|
103
139
|
const result = [];
|
|
104
140
|
const cursors = new Set();
|
|
@@ -144,50 +180,66 @@ function convertTool(server, def, client, timeout) {
|
|
|
144
180
|
},
|
|
145
181
|
};
|
|
146
182
|
}
|
|
147
|
-
/**
|
|
183
|
+
/** Create a fresh (not-yet-connected) client, with the elicitation bridge attached if a waitFor
|
|
184
|
+
* exists. Separated from connect so the caller ALWAYS holds a closeable reference — even if the
|
|
185
|
+
* connect is later abandoned by a timeout/abort, `client.close()` still terminates any spawned
|
|
186
|
+
* stdio child (no orphaned processes on cancellation). */
|
|
187
|
+
function newMcpClient(waitFor) {
|
|
188
|
+
const client = new Client({ name: "toolnexus", version: "0.1.0" }, { capabilities: waitFor ? { elicitation: {} } : {} });
|
|
189
|
+
if (waitFor) {
|
|
190
|
+
client.setRequestHandler(ElicitRequestSchema, async (request) => answerToElicitResult(await waitFor(elicitationToRequest(request.params))));
|
|
191
|
+
}
|
|
192
|
+
return client;
|
|
193
|
+
}
|
|
194
|
+
/** Connect an existing client's transport (remote streamable-HTTP with SSE fallback, or local stdio). */
|
|
195
|
+
async function connectTransport(client, cfg) {
|
|
196
|
+
if (isRemote(cfg)) {
|
|
197
|
+
const url = new URL(cfg.url);
|
|
198
|
+
const expanded = expandEnvHeaders(cfg.headers);
|
|
199
|
+
const requestInit = expanded ? { headers: expanded } : undefined;
|
|
200
|
+
try {
|
|
201
|
+
await client.connect(new StreamableHTTPClientTransport(url, { requestInit }));
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
await client.connect(new SSEClientTransport(url, { requestInit }));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
const local = cfg;
|
|
209
|
+
const [cmd, ...args] = local.command;
|
|
210
|
+
await client.connect(new StdioClientTransport({
|
|
211
|
+
command: cmd,
|
|
212
|
+
args,
|
|
213
|
+
cwd: local.cwd,
|
|
214
|
+
env: { ...process.env, ...(local.environment ?? local.env ?? {}) },
|
|
215
|
+
stderr: "pipe",
|
|
216
|
+
}));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
/** Connect to every enabled server, list + convert tools. Failures are isolated.
|
|
220
|
+
* §2 Gap 3: an optional `signal` bounds each connect/list by the server timeout and aborts the
|
|
221
|
+
* whole load promptly on parent cancellation. */
|
|
148
222
|
export async function loadMcp(input, opts) {
|
|
149
223
|
const config = parseMcpConfig(input);
|
|
150
224
|
const tools = [];
|
|
151
225
|
const status = {};
|
|
152
226
|
const clients = [];
|
|
153
227
|
const waitFor = opts?.waitFor;
|
|
228
|
+
const signal = opts?.signal;
|
|
154
229
|
await Promise.all(Object.entries(config).map(async ([name, cfg]) => {
|
|
155
230
|
if (!isEnabled(cfg)) {
|
|
156
231
|
status[name] = "disabled";
|
|
157
232
|
return;
|
|
158
233
|
}
|
|
159
234
|
const timeout = cfg.timeout ?? DEFAULT_TIMEOUT;
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
const client =
|
|
163
|
-
if (waitFor) {
|
|
164
|
-
client.setRequestHandler(ElicitRequestSchema, async (request) => answerToElicitResult(await waitFor(elicitationToRequest(request.params))));
|
|
165
|
-
}
|
|
235
|
+
// Create the client up front so a timed-out/aborted connect still has a closeable handle
|
|
236
|
+
// (its stdio child is killed by close() — never orphaned).
|
|
237
|
+
const client = newMcpClient(waitFor);
|
|
166
238
|
try {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
try {
|
|
172
|
-
await client.connect(new StreamableHTTPClientTransport(url, { requestInit }));
|
|
173
|
-
}
|
|
174
|
-
catch {
|
|
175
|
-
await client.connect(new SSEClientTransport(url, { requestInit }));
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
else {
|
|
179
|
-
const local = cfg;
|
|
180
|
-
const [cmd, ...args] = local.command;
|
|
181
|
-
await client.connect(new StdioClientTransport({
|
|
182
|
-
command: cmd,
|
|
183
|
-
args,
|
|
184
|
-
cwd: local.cwd,
|
|
185
|
-
env: { ...process.env, ...(local.environment ?? local.env ?? {}) },
|
|
186
|
-
stderr: "pipe",
|
|
187
|
-
}));
|
|
188
|
-
}
|
|
189
|
-
const defs = await paginateTools(client, timeout);
|
|
190
|
-
for (const def of defs)
|
|
239
|
+
await raceTimeout(connectTransport(client, cfg), timeout, signal);
|
|
240
|
+
const defs = await raceTimeout(paginateTools(client, timeout), timeout, signal);
|
|
241
|
+
const kept = applyToolsFilter(name, defs, cfg.tools);
|
|
242
|
+
for (const def of kept)
|
|
191
243
|
tools.push(convertTool(name, def, client, timeout));
|
|
192
244
|
clients.push(client);
|
|
193
245
|
status[name] = "connected";
|
|
@@ -195,7 +247,10 @@ export async function loadMcp(input, opts) {
|
|
|
195
247
|
catch (e) {
|
|
196
248
|
status[name] = "failed";
|
|
197
249
|
await client.close().catch(() => { });
|
|
198
|
-
//
|
|
250
|
+
// Parent cancellation/deadline aborts the whole load (§2 Gap 3, A1); a per-server timeout
|
|
251
|
+
// within budget isolates just this server.
|
|
252
|
+
if (signal?.aborted)
|
|
253
|
+
throw e;
|
|
199
254
|
console.warn(`[toolnexus] MCP server "${name}" failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
200
255
|
}
|
|
201
256
|
}));
|
|
@@ -207,3 +262,48 @@ export async function loadMcp(input, opts) {
|
|
|
207
262
|
},
|
|
208
263
|
};
|
|
209
264
|
}
|
|
265
|
+
/**
|
|
266
|
+
* §2 Gap 6. Connect to every enabled server, list tool definitions, and DISCONNECT before
|
|
267
|
+
* returning — no toolkit, no execute wiring, nothing left running. Failure isolation as in loadMcp.
|
|
268
|
+
*/
|
|
269
|
+
export async function listMcpTools(input, opts) {
|
|
270
|
+
const config = parseMcpConfig(input);
|
|
271
|
+
const toolsByServer = {};
|
|
272
|
+
const status = {};
|
|
273
|
+
const signal = opts?.signal;
|
|
274
|
+
await Promise.all(Object.entries(config).map(async ([name, cfg]) => {
|
|
275
|
+
if (!isEnabled(cfg)) {
|
|
276
|
+
status[name] = "disabled";
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
const timeout = cfg.timeout ?? DEFAULT_TIMEOUT;
|
|
280
|
+
// Client created up front so an aborted/timed-out connect is still closed (no orphaned child).
|
|
281
|
+
const client = newMcpClient();
|
|
282
|
+
try {
|
|
283
|
+
await raceTimeout(connectTransport(client, cfg), timeout, signal);
|
|
284
|
+
const defs = await raceTimeout(paginateTools(client, timeout), timeout, signal);
|
|
285
|
+
toolsByServer[name] = defs.map((d) => ({
|
|
286
|
+
name: d.name,
|
|
287
|
+
description: d.description ?? "",
|
|
288
|
+
inputSchema: {
|
|
289
|
+
...d.inputSchema,
|
|
290
|
+
type: "object",
|
|
291
|
+
properties: (d.inputSchema?.properties ?? {}),
|
|
292
|
+
},
|
|
293
|
+
}));
|
|
294
|
+
status[name] = "connected";
|
|
295
|
+
}
|
|
296
|
+
catch (e) {
|
|
297
|
+
status[name] = "failed";
|
|
298
|
+
if (signal?.aborted) {
|
|
299
|
+
await client.close().catch(() => { });
|
|
300
|
+
throw e;
|
|
301
|
+
}
|
|
302
|
+
console.warn(`[toolnexus] MCP server "${name}" failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
303
|
+
}
|
|
304
|
+
finally {
|
|
305
|
+
await client.close().catch(() => { });
|
|
306
|
+
}
|
|
307
|
+
}));
|
|
308
|
+
return { tools: toolsByServer, status };
|
|
309
|
+
}
|
package/dist/toolkit.d.ts
CHANGED
|
@@ -32,6 +32,9 @@ export interface ToolkitOptions {
|
|
|
32
32
|
* elicitation is not advertised.
|
|
33
33
|
*/
|
|
34
34
|
waitFor?: (request: import("./types.js").Request) => Promise<import("./types.js").Answer>;
|
|
35
|
+
/** §2 Gap 3. Bounds the MCP load: each connect/list is capped by the server timeout and the
|
|
36
|
+
* whole load aborts promptly if this signal fires. Omit ⇒ unbounded as before. */
|
|
37
|
+
signal?: AbortSignal;
|
|
35
38
|
}
|
|
36
39
|
export declare class Toolkit {
|
|
37
40
|
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, { waitFor: opts.waitFor }) : undefined;
|
|
37
|
+
const mcp = opts.mcpConfig !== undefined ? await loadMcp(opts.mcpConfig, { waitFor: opts.waitFor, signal: opts.signal }) : undefined;
|
|
38
38
|
// Skills come from directories, in-memory data, and/or a lazy provider (§3,
|
|
39
39
|
// S1). The provider is resolved here (create() is async) and merged with the
|
|
40
40
|
// data list; a provider failure is isolated so other sources still load.
|
package/package.json
CHANGED
package/src/client.ts
CHANGED
|
@@ -35,6 +35,18 @@ export interface ClientOptions {
|
|
|
35
35
|
* (open a browser, message a channel, watch a file, forward to another agent). Omit for a durable
|
|
36
36
|
* host: run() does not hang — it returns { status:"pending", pending:request } to resume later. */
|
|
37
37
|
waitFor?: (request: Request) => Promise<Answer>
|
|
38
|
+
/** §8 Gap 1. Extra top-level keys shallow-merged into EVERY LLM body after the client builds
|
|
39
|
+
* its own — a requestParams key WINS on collision (e.g. max_tokens overrides the anthropic
|
|
40
|
+
* default). `messages`/`tools`/`stream` are forbidden here (stripped + warned) — rewrite
|
|
41
|
+
* messages via bodyTransform. Applied on all four paths. Omit ⇒ body byte-identical to today. */
|
|
42
|
+
requestParams?: Record<string, unknown>
|
|
43
|
+
/** §8 Gap 1. Receives the fully assembled body (after requestParams merge) just before marshal
|
|
44
|
+
* and returns the body to send — the escape hatch for provider-specific rewriting (e.g. tool-role
|
|
45
|
+
* message preprocessing) without a proxy. Returning a falsy value ⇒ unchanged. Runs on every call. */
|
|
46
|
+
bodyTransform?: (body: Record<string, any>) => Record<string, any> | undefined | void
|
|
47
|
+
/** §8 Gap 2. HTTP transport for LLM requests (retries included). Omit ⇒ global fetch. Scope is the
|
|
48
|
+
* LLM path only; MCP transports use the SDK's own client. */
|
|
49
|
+
fetch?: typeof fetch
|
|
38
50
|
}
|
|
39
51
|
|
|
40
52
|
/**
|
|
@@ -281,6 +293,61 @@ export class Client {
|
|
|
281
293
|
this.store = opts.store ?? new InMemoryConversationStore()
|
|
282
294
|
}
|
|
283
295
|
|
|
296
|
+
/**
|
|
297
|
+
* §8 Gap 4. The client's conversation store — the exact instance passed in `opts.store`, else
|
|
298
|
+
* the default in-memory store the client created. Read (`get`) and write (`save`) it directly to
|
|
299
|
+
* share state with `ask`; no consumer needs a shadow copy.
|
|
300
|
+
*/
|
|
301
|
+
conversationStore(): ConversationStore {
|
|
302
|
+
return this.store
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* §8 Gap 1. Apply the request-shaping contract to an assembled body: strip forbidden keys from
|
|
307
|
+
* requestParams, shallow-merge requestParams (caller wins), then run bodyTransform last. Called at
|
|
308
|
+
* every LLM body-build site so the ordering contract holds identically on all four paths.
|
|
309
|
+
*/
|
|
310
|
+
private finalizeBody(body: Record<string, any>): Record<string, any> {
|
|
311
|
+
const rp = this.opts.requestParams
|
|
312
|
+
if (rp) {
|
|
313
|
+
for (const [k, v] of Object.entries(rp)) {
|
|
314
|
+
if (k === "messages" || k === "tools" || k === "stream") {
|
|
315
|
+
console.warn(`[toolnexus] requestParams key "${k}" is not allowed — ignored (use bodyTransform)`)
|
|
316
|
+
continue
|
|
317
|
+
}
|
|
318
|
+
body[k] = v
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (this.opts.bodyTransform) {
|
|
322
|
+
const out = this.opts.bodyTransform(body)
|
|
323
|
+
if (out) return out
|
|
324
|
+
}
|
|
325
|
+
return body
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** §8 Gap 5. OpenAI-style body; omits tools/tool_choice when the tool list is empty. Key order
|
|
329
|
+
* matches the pre-change body so a no-options call is byte-identical. */
|
|
330
|
+
private openaiBody(messages: any[], tools: any[], stream: boolean): Record<string, any> {
|
|
331
|
+
const b: Record<string, any> = { model: this.opts.model, messages }
|
|
332
|
+
if (tools.length) {
|
|
333
|
+
b.tools = tools
|
|
334
|
+
b.tool_choice = "auto"
|
|
335
|
+
}
|
|
336
|
+
if (stream) {
|
|
337
|
+
b.stream = true
|
|
338
|
+
b.stream_options = { include_usage: true }
|
|
339
|
+
}
|
|
340
|
+
return this.finalizeBody(b)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** §8 Gap 5. Anthropic-style body; omits tools when the tool list is empty. */
|
|
344
|
+
private anthropicBody(system: string, messages: any[], tools: any[], stream: boolean): Record<string, any> {
|
|
345
|
+
const b: Record<string, any> = { model: this.opts.model, max_tokens: 4096, system, messages }
|
|
346
|
+
if (tools.length) b.tools = tools
|
|
347
|
+
if (stream) b.stream = true
|
|
348
|
+
return this.finalizeBody(b)
|
|
349
|
+
}
|
|
350
|
+
|
|
284
351
|
/** Prometheus text exposition of cumulative metrics (§8). Always valid, empty-but-valid before activity. */
|
|
285
352
|
metrics(): string {
|
|
286
353
|
return this.registry.render()
|
|
@@ -379,7 +446,7 @@ export class Client {
|
|
|
379
446
|
let lastErr: unknown
|
|
380
447
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
381
448
|
try {
|
|
382
|
-
const res = await fetch(url, { ...init, signal })
|
|
449
|
+
const res = await (this.opts.fetch ?? fetch)(url, { ...init, signal })
|
|
383
450
|
if (res.ok || !RETRYABLE.has(res.status) || attempt === retries) return res
|
|
384
451
|
const ra = Number(res.headers.get("retry-after"))
|
|
385
452
|
await delay(ra ? ra * 1000 : base * 2 ** attempt + Math.random() * 100, signal)
|
|
@@ -470,7 +537,7 @@ export class Client {
|
|
|
470
537
|
const data: any = await this.llmCallJson(`${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`, {
|
|
471
538
|
method: "POST",
|
|
472
539
|
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", ...this.opts.headers },
|
|
473
|
-
body: JSON.stringify(
|
|
540
|
+
body: JSON.stringify(this.openaiBody(messages, tools, false)),
|
|
474
541
|
}, signal, "openai")
|
|
475
542
|
addUsage(usage, data.usage, "openai")
|
|
476
543
|
if (this.opts.hooks?.afterLLM) await this.opts.hooks.afterLLM({ response: data, model: this.opts.model, turn })
|
|
@@ -570,7 +637,7 @@ export class Client {
|
|
|
570
637
|
"Content-Type": "application/json",
|
|
571
638
|
...this.opts.headers,
|
|
572
639
|
},
|
|
573
|
-
body: JSON.stringify(
|
|
640
|
+
body: JSON.stringify(this.anthropicBody(system, messages, tools, false)),
|
|
574
641
|
}, signal, "anthropic")
|
|
575
642
|
addUsage(usage, data.usage, "anthropic")
|
|
576
643
|
if (this.opts.hooks?.afterLLM) await this.opts.hooks.afterLLM({ response: data, model: this.opts.model, turn })
|
|
@@ -646,7 +713,7 @@ export class Client {
|
|
|
646
713
|
const res = await this.llmFetch(`${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`, {
|
|
647
714
|
method: "POST",
|
|
648
715
|
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", ...this.opts.headers },
|
|
649
|
-
body: JSON.stringify(
|
|
716
|
+
body: JSON.stringify(this.openaiBody(messages, tools, true)),
|
|
650
717
|
}, signal)
|
|
651
718
|
if (!res.ok || !res.body) throw new Error(`LLM ${res.status}: ${await res.text()}`)
|
|
652
719
|
for await (const line of sseLines(res.body)) {
|
|
@@ -740,7 +807,7 @@ export class Client {
|
|
|
740
807
|
const res = await this.llmFetch(endpoint, {
|
|
741
808
|
method: "POST",
|
|
742
809
|
headers: { "x-api-key": key, "anthropic-version": "2023-06-01", "Content-Type": "application/json", ...this.opts.headers },
|
|
743
|
-
body: JSON.stringify(
|
|
810
|
+
body: JSON.stringify(this.anthropicBody(system, messages, tools, true)),
|
|
744
811
|
}, signal)
|
|
745
812
|
if (!res.ok || !res.body) throw new Error(`LLM ${res.status}: ${await res.text()}`)
|
|
746
813
|
for await (const line of sseLines(res.body)) {
|
package/src/mcp.ts
CHANGED
|
@@ -51,6 +51,10 @@ export interface LocalServer {
|
|
|
51
51
|
enabled?: boolean
|
|
52
52
|
disabled?: boolean
|
|
53
53
|
timeout?: number
|
|
54
|
+
/** §2 Gap 7. Per-server tool allowlist keyed on ORIGINAL tool name; same semantics as the
|
|
55
|
+
* builtins/skills filter (nil/empty ⇒ all; ≥1 true ⇒ allowlist; only-false ⇒ drop-list; unknown
|
|
56
|
+
* ⇒ ignore+warn). Applied to the listed defs before sanitize/prefix. */
|
|
57
|
+
tools?: Record<string, boolean>
|
|
54
58
|
}
|
|
55
59
|
|
|
56
60
|
export interface RemoteServer {
|
|
@@ -60,6 +64,8 @@ export interface RemoteServer {
|
|
|
60
64
|
enabled?: boolean
|
|
61
65
|
disabled?: boolean
|
|
62
66
|
timeout?: number
|
|
67
|
+
/** §2 Gap 7. Per-server tool allowlist (see LocalServer.tools). */
|
|
68
|
+
tools?: Record<string, boolean>
|
|
63
69
|
}
|
|
64
70
|
|
|
65
71
|
export type ServerConfig = LocalServer | RemoteServer
|
|
@@ -128,6 +134,40 @@ function joinTextContent(content: unknown): string {
|
|
|
128
134
|
.join("\n")
|
|
129
135
|
}
|
|
130
136
|
|
|
137
|
+
/**
|
|
138
|
+
* §2 Gap 7. Apply a per-server tool allowlist to the listed defs. Same semantics as the builtins
|
|
139
|
+
* and skills filters: nil/empty ⇒ all; ≥1 true ⇒ allowlist (only true names); only-false ⇒ drop-list
|
|
140
|
+
* over all-on; unknown names ignored + warned once. Keyed on ORIGINAL tool name.
|
|
141
|
+
*/
|
|
142
|
+
function applyToolsFilter(server: string, defs: McpToolDef[], filter?: Record<string, boolean>): McpToolDef[] {
|
|
143
|
+
if (!filter) return defs
|
|
144
|
+
const keys = Object.keys(filter)
|
|
145
|
+
if (keys.length === 0) return defs
|
|
146
|
+
const hasTrue = keys.some((k) => filter[k] === true)
|
|
147
|
+
const present = new Set(defs.map((d) => d.name))
|
|
148
|
+
for (const k of keys) {
|
|
149
|
+
if (!present.has(k)) console.warn(`[toolnexus] server "${server}" tools filter name "${k}" matched no tool`)
|
|
150
|
+
}
|
|
151
|
+
return defs.filter((d) => (hasTrue ? filter[d.name] === true : filter[d.name] !== false))
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* §2 Gap 3. Race a promise against a per-server timeout and the caller's abort signal, so a hung
|
|
156
|
+
* connect/list is bounded (the SSE fallback included) and a parent cancellation aborts promptly.
|
|
157
|
+
*/
|
|
158
|
+
function raceTimeout<T>(p: Promise<T>, ms: number, signal?: AbortSignal): Promise<T> {
|
|
159
|
+
return new Promise<T>((resolve, reject) => {
|
|
160
|
+
const onAbort = () => { cleanup(); reject((signal as any)?.reason ?? new Error("aborted")) }
|
|
161
|
+
const t = setTimeout(() => { cleanup(); reject(new Error("mcp connect/list timeout")) }, ms)
|
|
162
|
+
const cleanup = () => { clearTimeout(t); signal?.removeEventListener("abort", onAbort) }
|
|
163
|
+
if (signal) {
|
|
164
|
+
if (signal.aborted) return onAbort()
|
|
165
|
+
signal.addEventListener("abort", onAbort, { once: true })
|
|
166
|
+
}
|
|
167
|
+
p.then((v) => { cleanup(); resolve(v) }, (e) => { cleanup(); reject(e) })
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
131
171
|
async function paginateTools(client: Client, timeout: number): Promise<McpToolDef[]> {
|
|
132
172
|
const result: McpToolDef[] = []
|
|
133
173
|
const cursors = new Set<string>()
|
|
@@ -182,13 +222,59 @@ export interface McpSource {
|
|
|
182
222
|
close(): Promise<void>
|
|
183
223
|
}
|
|
184
224
|
|
|
185
|
-
/**
|
|
186
|
-
|
|
225
|
+
/** Create a fresh (not-yet-connected) client, with the elicitation bridge attached if a waitFor
|
|
226
|
+
* exists. Separated from connect so the caller ALWAYS holds a closeable reference — even if the
|
|
227
|
+
* connect is later abandoned by a timeout/abort, `client.close()` still terminates any spawned
|
|
228
|
+
* stdio child (no orphaned processes on cancellation). */
|
|
229
|
+
function newMcpClient(waitFor?: (request: Request) => Promise<Answer>): Client {
|
|
230
|
+
const client = new Client({ name: "toolnexus", version: "0.1.0" }, { capabilities: waitFor ? { elicitation: {} } : {} })
|
|
231
|
+
if (waitFor) {
|
|
232
|
+
client.setRequestHandler(ElicitRequestSchema, async (request) =>
|
|
233
|
+
answerToElicitResult(await waitFor(elicitationToRequest(request.params))),
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
return client
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Connect an existing client's transport (remote streamable-HTTP with SSE fallback, or local stdio). */
|
|
240
|
+
async function connectTransport(client: Client, cfg: ServerConfig): Promise<void> {
|
|
241
|
+
if (isRemote(cfg)) {
|
|
242
|
+
const url = new URL(cfg.url)
|
|
243
|
+
const expanded = expandEnvHeaders(cfg.headers)
|
|
244
|
+
const requestInit = expanded ? { headers: expanded } : undefined
|
|
245
|
+
try {
|
|
246
|
+
await client.connect(new StreamableHTTPClientTransport(url, { requestInit }))
|
|
247
|
+
} catch {
|
|
248
|
+
await client.connect(new SSEClientTransport(url, { requestInit }))
|
|
249
|
+
}
|
|
250
|
+
} else {
|
|
251
|
+
const local = cfg as LocalServer
|
|
252
|
+
const [cmd, ...args] = local.command
|
|
253
|
+
await client.connect(
|
|
254
|
+
new StdioClientTransport({
|
|
255
|
+
command: cmd,
|
|
256
|
+
args,
|
|
257
|
+
cwd: local.cwd,
|
|
258
|
+
env: { ...(process.env as Record<string, string>), ...(local.environment ?? local.env ?? {}) },
|
|
259
|
+
stderr: "pipe",
|
|
260
|
+
}),
|
|
261
|
+
)
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Connect to every enabled server, list + convert tools. Failures are isolated.
|
|
266
|
+
* §2 Gap 3: an optional `signal` bounds each connect/list by the server timeout and aborts the
|
|
267
|
+
* whole load promptly on parent cancellation. */
|
|
268
|
+
export async function loadMcp(
|
|
269
|
+
input: string | object,
|
|
270
|
+
opts?: { waitFor?: (request: Request) => Promise<Answer>; signal?: AbortSignal },
|
|
271
|
+
): Promise<McpSource> {
|
|
187
272
|
const config = parseMcpConfig(input)
|
|
188
273
|
const tools: Tool[] = []
|
|
189
274
|
const status: Record<string, McpStatus> = {}
|
|
190
275
|
const clients: Client[] = []
|
|
191
276
|
const waitFor = opts?.waitFor
|
|
277
|
+
const signal = opts?.signal
|
|
192
278
|
|
|
193
279
|
await Promise.all(
|
|
194
280
|
Object.entries(config).map(async ([name, cfg]) => {
|
|
@@ -197,45 +283,22 @@ export async function loadMcp(input: string | object, opts?: { waitFor?: (reques
|
|
|
197
283
|
return
|
|
198
284
|
}
|
|
199
285
|
const timeout = cfg.timeout ?? DEFAULT_TIMEOUT
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
const client =
|
|
203
|
-
if (waitFor) {
|
|
204
|
-
client.setRequestHandler(ElicitRequestSchema, async (request) =>
|
|
205
|
-
answerToElicitResult(await waitFor(elicitationToRequest(request.params))),
|
|
206
|
-
)
|
|
207
|
-
}
|
|
286
|
+
// Create the client up front so a timed-out/aborted connect still has a closeable handle
|
|
287
|
+
// (its stdio child is killed by close() — never orphaned).
|
|
288
|
+
const client = newMcpClient(waitFor)
|
|
208
289
|
try {
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
try {
|
|
214
|
-
await client.connect(new StreamableHTTPClientTransport(url, { requestInit }))
|
|
215
|
-
} catch {
|
|
216
|
-
await client.connect(new SSEClientTransport(url, { requestInit }))
|
|
217
|
-
}
|
|
218
|
-
} else {
|
|
219
|
-
const local = cfg as LocalServer
|
|
220
|
-
const [cmd, ...args] = local.command
|
|
221
|
-
await client.connect(
|
|
222
|
-
new StdioClientTransport({
|
|
223
|
-
command: cmd,
|
|
224
|
-
args,
|
|
225
|
-
cwd: local.cwd,
|
|
226
|
-
env: { ...(process.env as Record<string, string>), ...(local.environment ?? local.env ?? {}) },
|
|
227
|
-
stderr: "pipe",
|
|
228
|
-
}),
|
|
229
|
-
)
|
|
230
|
-
}
|
|
231
|
-
const defs = await paginateTools(client, timeout)
|
|
232
|
-
for (const def of defs) tools.push(convertTool(name, def, client, timeout))
|
|
290
|
+
await raceTimeout(connectTransport(client, cfg), timeout, signal)
|
|
291
|
+
const defs = await raceTimeout(paginateTools(client, timeout), timeout, signal)
|
|
292
|
+
const kept = applyToolsFilter(name, defs, (cfg as { tools?: Record<string, boolean> }).tools)
|
|
293
|
+
for (const def of kept) tools.push(convertTool(name, def, client, timeout))
|
|
233
294
|
clients.push(client)
|
|
234
295
|
status[name] = "connected"
|
|
235
296
|
} catch (e) {
|
|
236
297
|
status[name] = "failed"
|
|
237
298
|
await client.close().catch(() => {})
|
|
238
|
-
//
|
|
299
|
+
// Parent cancellation/deadline aborts the whole load (§2 Gap 3, A1); a per-server timeout
|
|
300
|
+
// within budget isolates just this server.
|
|
301
|
+
if (signal?.aborted) throw e
|
|
239
302
|
console.warn(`[toolnexus] MCP server "${name}" failed: ${e instanceof Error ? e.message : String(e)}`)
|
|
240
303
|
}
|
|
241
304
|
}),
|
|
@@ -249,3 +312,62 @@ export async function loadMcp(input: string | object, opts?: { waitFor?: (reques
|
|
|
249
312
|
},
|
|
250
313
|
}
|
|
251
314
|
}
|
|
315
|
+
|
|
316
|
+
/** §2 Gap 6. One listed tool definition (original, unprefixed name). */
|
|
317
|
+
export interface ToolInfo {
|
|
318
|
+
name: string
|
|
319
|
+
description: string
|
|
320
|
+
inputSchema: JSONSchema
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** §2 Gap 6. Result of a list-only pass over an MCP config. */
|
|
324
|
+
export interface McpInventory {
|
|
325
|
+
/** server → its full listed tool defs, UNFILTERED by ServerConfig.tools (authors the allowlist). */
|
|
326
|
+
tools: Record<string, ToolInfo[]>
|
|
327
|
+
status: Record<string, McpStatus>
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* §2 Gap 6. Connect to every enabled server, list tool definitions, and DISCONNECT before
|
|
332
|
+
* returning — no toolkit, no execute wiring, nothing left running. Failure isolation as in loadMcp.
|
|
333
|
+
*/
|
|
334
|
+
export async function listMcpTools(input: string | object, opts?: { signal?: AbortSignal }): Promise<McpInventory> {
|
|
335
|
+
const config = parseMcpConfig(input)
|
|
336
|
+
const toolsByServer: Record<string, ToolInfo[]> = {}
|
|
337
|
+
const status: Record<string, McpStatus> = {}
|
|
338
|
+
const signal = opts?.signal
|
|
339
|
+
|
|
340
|
+
await Promise.all(
|
|
341
|
+
Object.entries(config).map(async ([name, cfg]) => {
|
|
342
|
+
if (!isEnabled(cfg)) {
|
|
343
|
+
status[name] = "disabled"
|
|
344
|
+
return
|
|
345
|
+
}
|
|
346
|
+
const timeout = cfg.timeout ?? DEFAULT_TIMEOUT
|
|
347
|
+
// Client created up front so an aborted/timed-out connect is still closed (no orphaned child).
|
|
348
|
+
const client = newMcpClient()
|
|
349
|
+
try {
|
|
350
|
+
await raceTimeout(connectTransport(client, cfg), timeout, signal)
|
|
351
|
+
const defs = await raceTimeout(paginateTools(client, timeout), timeout, signal)
|
|
352
|
+
toolsByServer[name] = defs.map((d) => ({
|
|
353
|
+
name: d.name,
|
|
354
|
+
description: d.description ?? "",
|
|
355
|
+
inputSchema: {
|
|
356
|
+
...(d.inputSchema as object),
|
|
357
|
+
type: "object",
|
|
358
|
+
properties: (d.inputSchema?.properties ?? {}) as Record<string, unknown>,
|
|
359
|
+
} as JSONSchema,
|
|
360
|
+
}))
|
|
361
|
+
status[name] = "connected"
|
|
362
|
+
} catch (e) {
|
|
363
|
+
status[name] = "failed"
|
|
364
|
+
if (signal?.aborted) { await client.close().catch(() => {}); throw e }
|
|
365
|
+
console.warn(`[toolnexus] MCP server "${name}" failed: ${e instanceof Error ? e.message : String(e)}`)
|
|
366
|
+
} finally {
|
|
367
|
+
await client.close().catch(() => {})
|
|
368
|
+
}
|
|
369
|
+
}),
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
return { tools: toolsByServer, status }
|
|
373
|
+
}
|
package/src/toolkit.ts
CHANGED
|
@@ -35,6 +35,9 @@ export interface ToolkitOptions {
|
|
|
35
35
|
* elicitation is not advertised.
|
|
36
36
|
*/
|
|
37
37
|
waitFor?: (request: import("./types.js").Request) => Promise<import("./types.js").Answer>
|
|
38
|
+
/** §2 Gap 3. Bounds the MCP load: each connect/list is capped by the server timeout and the
|
|
39
|
+
* whole load aborts promptly if this signal fires. Omit ⇒ unbounded as before. */
|
|
40
|
+
signal?: AbortSignal
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
export class Toolkit {
|
|
@@ -66,7 +69,7 @@ export class Toolkit {
|
|
|
66
69
|
}
|
|
67
70
|
|
|
68
71
|
static async create(opts: ToolkitOptions): Promise<Toolkit> {
|
|
69
|
-
const mcp = opts.mcpConfig !== undefined ? await loadMcp(opts.mcpConfig, { waitFor: opts.waitFor }) : undefined
|
|
72
|
+
const mcp = opts.mcpConfig !== undefined ? await loadMcp(opts.mcpConfig, { waitFor: opts.waitFor, signal: opts.signal }) : undefined
|
|
70
73
|
// Skills come from directories, in-memory data, and/or a lazy provider (§3,
|
|
71
74
|
// S1). The provider is resolved here (create() is async) and merged with the
|
|
72
75
|
// data list; a provider failure is isolated so other sources still load.
|