toolnexus 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/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/serve.js +7 -1
- package/dist/skill.d.ts +44 -2
- package/dist/skill.js +160 -45
- package/dist/toolkit.d.ts +12 -0
- package/dist/toolkit.js +24 -2
- package/package.json +1 -1
- package/src/client.ts +72 -5
- package/src/mcp.ts +157 -35
- package/src/serve.ts +7 -1
- package/src/skill.ts +221 -51
- package/src/toolkit.ts +35 -3
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/serve.js
CHANGED
|
@@ -50,7 +50,13 @@ export class FileTaskStore {
|
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
async save(task) {
|
|
53
|
-
|
|
53
|
+
// Atomic write: a concurrent get() must never read a half-written file (that
|
|
54
|
+
// would surface as a spurious "Task not found" mid-poll). Write a temp file in
|
|
55
|
+
// the same dir, then rename over the target.
|
|
56
|
+
const target = this.file(task.id);
|
|
57
|
+
const tmp = `${target}.tmp`;
|
|
58
|
+
fs.writeFileSync(tmp, JSON.stringify(task));
|
|
59
|
+
fs.renameSync(tmp, target);
|
|
54
60
|
}
|
|
55
61
|
}
|
|
56
62
|
/** Map a `store` selector to a concrete TaskStore. `"file:<dir>"` → FileTaskStore. */
|
package/dist/skill.d.ts
CHANGED
|
@@ -10,6 +10,42 @@ export interface SkillInfo {
|
|
|
10
10
|
description?: string;
|
|
11
11
|
location: string;
|
|
12
12
|
content: string;
|
|
13
|
+
/** Internal origin discriminator; "fs" ⇒ on-disk (default), "logical" ⇒ data. */
|
|
14
|
+
origin?: "fs" | "logical";
|
|
15
|
+
/** Logical resource list (data-sourced skills only). */
|
|
16
|
+
resources?: string[];
|
|
17
|
+
/** Logical base, e.g. skill://name/ (data-sourced skills only). */
|
|
18
|
+
base?: string;
|
|
19
|
+
}
|
|
20
|
+
/** A skill supplied directly as data, bypassing the filesystem (SPEC.md §3, S1). */
|
|
21
|
+
export interface SkillDef {
|
|
22
|
+
name: string;
|
|
23
|
+
description?: string;
|
|
24
|
+
content: string;
|
|
25
|
+
/** Optional logical resource names surfaced in the invoke-time <skill_files> block. */
|
|
26
|
+
resources?: string[];
|
|
27
|
+
/** Optional logical base; defaults to `skill://<name>/`. */
|
|
28
|
+
base?: string;
|
|
29
|
+
}
|
|
30
|
+
/** Why a candidate SKILL.md did not become a skill (SPEC.md §3, S3). */
|
|
31
|
+
export type SkillSkipReason = "missing-name" | "malformed-frontmatter" | "duplicate-name" | "unreadable";
|
|
32
|
+
export interface SkillSkip {
|
|
33
|
+
location: string;
|
|
34
|
+
reason: SkillSkipReason;
|
|
35
|
+
}
|
|
36
|
+
/** Result of a list-only validate pass (SPEC.md §3, S3). */
|
|
37
|
+
export interface SkillInventory {
|
|
38
|
+
skills: SkillInfo[];
|
|
39
|
+
skipped: SkillSkip[];
|
|
40
|
+
}
|
|
41
|
+
/** Options for loadSkills / listSkills (SPEC.md §3, S1/S2/S5). */
|
|
42
|
+
export interface LoadSkillsOptions {
|
|
43
|
+
dirs?: string | string[];
|
|
44
|
+
skills?: SkillDef[];
|
|
45
|
+
/** Per-agent allowlist keyed on skill name; same semantics as the MCP tools filter. */
|
|
46
|
+
filter?: Record<string, boolean>;
|
|
47
|
+
/** Sibling-file sample cap: 0 ⇒ default 10, n>0 ⇒ cap, -1 ⇒ omit <skill_files>. */
|
|
48
|
+
sampleLimit?: number;
|
|
13
49
|
}
|
|
14
50
|
export interface SkillSource {
|
|
15
51
|
skills: Record<string, SkillInfo>;
|
|
@@ -17,5 +53,11 @@ export interface SkillSource {
|
|
|
17
53
|
/** Markdown catalog for the system prompt (mirrors opencode Skill.fmt). */
|
|
18
54
|
prompt(): string;
|
|
19
55
|
}
|
|
20
|
-
/**
|
|
21
|
-
|
|
56
|
+
/**
|
|
57
|
+
* Discover + validate skills from the same sources loadSkills accepts, returning
|
|
58
|
+
* parsed skills plus typed skip reasons — no toolkit wired (SPEC.md §3, S3). The
|
|
59
|
+
* inventory is UNFILTERED (it exists to author/validate the allowlist).
|
|
60
|
+
*/
|
|
61
|
+
export declare function listSkills(input: string | string[] | LoadSkillsOptions): SkillInventory;
|
|
62
|
+
/** Discover skills (dirs and/or data) and build the `skill` loader tool. */
|
|
63
|
+
export declare function loadSkills(input: string | string[] | LoadSkillsOptions): SkillSource;
|