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/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/serve.ts
CHANGED
|
@@ -62,7 +62,13 @@ export class FileTaskStore implements TaskStore {
|
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
64
|
async save(task: A2ATask): Promise<void> {
|
|
65
|
-
|
|
65
|
+
// Atomic write: a concurrent get() must never read a half-written file (that
|
|
66
|
+
// would surface as a spurious "Task not found" mid-poll). Write a temp file in
|
|
67
|
+
// the same dir, then rename over the target.
|
|
68
|
+
const target = this.file(task.id)
|
|
69
|
+
const tmp = `${target}.tmp`
|
|
70
|
+
fs.writeFileSync(tmp, JSON.stringify(task))
|
|
71
|
+
fs.renameSync(tmp, target)
|
|
66
72
|
}
|
|
67
73
|
}
|
|
68
74
|
|
package/src/skill.ts
CHANGED
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
* discover **\/SKILL.md, parse frontmatter, and expose ONE `skill` tool that
|
|
4
4
|
* loads a skill's instructions + sampled resources on demand (progressive
|
|
5
5
|
* disclosure).
|
|
6
|
+
*
|
|
7
|
+
* Beyond on-disk discovery the source also accepts skills supplied as data
|
|
8
|
+
* (`SkillDef`) — SPEC.md §3. Directory-sourced skills keep the exact `file://`
|
|
9
|
+
* base + on-disk sibling sampling (byte-identical); data-sourced skills use a
|
|
10
|
+
* logical `skill://name/` base + a supplied resource list and never touch disk.
|
|
6
11
|
*/
|
|
7
12
|
import { readFileSync, readdirSync, realpathSync, statSync } from "node:fs"
|
|
8
13
|
import path from "node:path"
|
|
@@ -27,24 +32,72 @@ export const SKILLS_PROMPT_PREAMBLE =
|
|
|
27
32
|
export interface SkillInfo {
|
|
28
33
|
name: string
|
|
29
34
|
description?: string
|
|
30
|
-
location: string // absolute path to SKILL.md
|
|
35
|
+
location: string // absolute path to SKILL.md (fs) or logical base (data)
|
|
31
36
|
content: string // body after frontmatter
|
|
37
|
+
/** Internal origin discriminator; "fs" ⇒ on-disk (default), "logical" ⇒ data. */
|
|
38
|
+
origin?: "fs" | "logical"
|
|
39
|
+
/** Logical resource list (data-sourced skills only). */
|
|
40
|
+
resources?: string[]
|
|
41
|
+
/** Logical base, e.g. skill://name/ (data-sourced skills only). */
|
|
42
|
+
base?: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A skill supplied directly as data, bypassing the filesystem (SPEC.md §3, S1). */
|
|
46
|
+
export interface SkillDef {
|
|
47
|
+
name: string
|
|
48
|
+
description?: string
|
|
49
|
+
content: string
|
|
50
|
+
/** Optional logical resource names surfaced in the invoke-time <skill_files> block. */
|
|
51
|
+
resources?: string[]
|
|
52
|
+
/** Optional logical base; defaults to `skill://<name>/`. */
|
|
53
|
+
base?: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Why a candidate SKILL.md did not become a skill (SPEC.md §3, S3). */
|
|
57
|
+
export type SkillSkipReason = "missing-name" | "malformed-frontmatter" | "duplicate-name" | "unreadable"
|
|
58
|
+
|
|
59
|
+
export interface SkillSkip {
|
|
60
|
+
location: string
|
|
61
|
+
reason: SkillSkipReason
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Result of a list-only validate pass (SPEC.md §3, S3). */
|
|
65
|
+
export interface SkillInventory {
|
|
66
|
+
skills: SkillInfo[]
|
|
67
|
+
skipped: SkillSkip[]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Options for loadSkills / listSkills (SPEC.md §3, S1/S2/S5). */
|
|
71
|
+
export interface LoadSkillsOptions {
|
|
72
|
+
dirs?: string | string[]
|
|
73
|
+
skills?: SkillDef[]
|
|
74
|
+
/** Per-agent allowlist keyed on skill name; same semantics as the MCP tools filter. */
|
|
75
|
+
filter?: Record<string, boolean>
|
|
76
|
+
/** Sibling-file sample cap: 0 ⇒ default 10, n>0 ⇒ cap, -1 ⇒ omit <skill_files>. */
|
|
77
|
+
sampleLimit?: number
|
|
32
78
|
}
|
|
33
79
|
|
|
34
80
|
/**
|
|
35
81
|
* Parse YAML frontmatter (between the leading `---` fences) with a real YAML
|
|
36
82
|
* parser, so folded (`>`)/literal (`|`) block scalars, quoting, and multi-line
|
|
37
83
|
* values all resolve correctly. Scalar values are coerced to strings.
|
|
84
|
+
*
|
|
85
|
+
* `malformed` is true only when fences are present but the YAML fails to parse —
|
|
86
|
+
* distinguishing a malformed header from a body with no frontmatter, so the
|
|
87
|
+
* inventory (S3) can report the right skip reason. loadSkills' behavior is
|
|
88
|
+
* unchanged: a malformed header yields no name and the skill is skipped.
|
|
38
89
|
*/
|
|
39
|
-
function parseFrontmatter(text: string): { data: Record<string, string>; content: string } {
|
|
90
|
+
function parseFrontmatter(text: string): { data: Record<string, string>; content: string; malformed: boolean } {
|
|
40
91
|
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text)
|
|
41
|
-
if (!match) return { data: {}, content: text }
|
|
92
|
+
if (!match) return { data: {}, content: text, malformed: false }
|
|
42
93
|
const data: Record<string, string> = {}
|
|
43
94
|
let parsed: unknown
|
|
95
|
+
let malformed = false
|
|
44
96
|
try {
|
|
45
97
|
parsed = parseYaml(match[1])
|
|
46
98
|
} catch {
|
|
47
99
|
parsed = null
|
|
100
|
+
malformed = true
|
|
48
101
|
}
|
|
49
102
|
if (parsed && typeof parsed === "object") {
|
|
50
103
|
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
|
|
@@ -55,7 +108,7 @@ function parseFrontmatter(text: string): { data: Record<string, string>; content
|
|
|
55
108
|
}
|
|
56
109
|
}
|
|
57
110
|
}
|
|
58
|
-
return { data, content: match[2] }
|
|
111
|
+
return { data, content: match[2], malformed }
|
|
59
112
|
}
|
|
60
113
|
|
|
61
114
|
function walkSkillFiles(root: string): string[] {
|
|
@@ -150,50 +203,145 @@ function sampleSiblingFiles(dir: string, limit = 10): string[] {
|
|
|
150
203
|
return out
|
|
151
204
|
}
|
|
152
205
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
prompt(): string
|
|
206
|
+
/** One raw candidate before cross-source dedupe: a parsed skill OR a typed skip. */
|
|
207
|
+
interface RawCandidate {
|
|
208
|
+
info?: SkillInfo
|
|
209
|
+
skip?: SkillSkip
|
|
158
210
|
}
|
|
159
211
|
|
|
160
|
-
/** Discover
|
|
161
|
-
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
212
|
+
/** Discover candidates under one on-disk root (order = discovery order). */
|
|
213
|
+
function candidatesFromDir(root: string): RawCandidate[] {
|
|
214
|
+
const out: RawCandidate[] = []
|
|
215
|
+
let isDir = false
|
|
216
|
+
try {
|
|
217
|
+
isDir = statSync(root).isDirectory()
|
|
218
|
+
} catch {
|
|
219
|
+
isDir = false
|
|
220
|
+
}
|
|
221
|
+
if (!isDir) {
|
|
222
|
+
console.warn(`[toolnexus] skills dir not found: ${root}`)
|
|
223
|
+
return out
|
|
224
|
+
}
|
|
225
|
+
for (const file of walkSkillFiles(root)) {
|
|
226
|
+
let text: string
|
|
167
227
|
try {
|
|
168
|
-
|
|
228
|
+
text = readFileSync(file, "utf8")
|
|
169
229
|
} catch {
|
|
170
|
-
|
|
230
|
+
out.push({ skip: { location: file, reason: "unreadable" } })
|
|
231
|
+
continue
|
|
171
232
|
}
|
|
172
|
-
|
|
173
|
-
|
|
233
|
+
const { data, content, malformed } = parseFrontmatter(text)
|
|
234
|
+
if (malformed) {
|
|
235
|
+
out.push({ skip: { location: file, reason: "malformed-frontmatter" } })
|
|
174
236
|
continue
|
|
175
237
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
text = readFileSync(file, "utf8")
|
|
180
|
-
} catch {
|
|
181
|
-
continue
|
|
182
|
-
}
|
|
183
|
-
const { data, content } = parseFrontmatter(text)
|
|
184
|
-
if (!data.name) continue
|
|
185
|
-
if (skills[data.name]) {
|
|
186
|
-
console.warn(`[toolnexus] duplicate skill name "${data.name}" (${file}) — keeping first`)
|
|
187
|
-
continue
|
|
188
|
-
}
|
|
189
|
-
skills[data.name] = {
|
|
190
|
-
name: data.name,
|
|
191
|
-
description: data.description,
|
|
192
|
-
location: file,
|
|
193
|
-
content,
|
|
194
|
-
}
|
|
238
|
+
if (!data.name) {
|
|
239
|
+
out.push({ skip: { location: file, reason: "missing-name" } })
|
|
240
|
+
continue
|
|
195
241
|
}
|
|
242
|
+
out.push({ info: { name: data.name, description: data.description, location: file, content, origin: "fs" } })
|
|
196
243
|
}
|
|
244
|
+
return out
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Turn data-supplied skill defs into candidates (logical origin). */
|
|
248
|
+
function candidatesFromDefs(defs: SkillDef[]): RawCandidate[] {
|
|
249
|
+
return defs.map((d): RawCandidate => {
|
|
250
|
+
if (!d.name) return { skip: { location: d.base ?? "skill://", reason: "missing-name" } }
|
|
251
|
+
const base = d.base && d.base.length > 0 ? d.base : `skill://${d.name}/`
|
|
252
|
+
return {
|
|
253
|
+
info: {
|
|
254
|
+
name: d.name,
|
|
255
|
+
description: d.description,
|
|
256
|
+
location: base,
|
|
257
|
+
content: d.content ?? "",
|
|
258
|
+
origin: "logical",
|
|
259
|
+
resources: d.resources ?? [],
|
|
260
|
+
base,
|
|
261
|
+
},
|
|
262
|
+
}
|
|
263
|
+
})
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Collect ordered candidates from dirs then data defs. */
|
|
267
|
+
function collectCandidates(opts: LoadSkillsOptions): RawCandidate[] {
|
|
268
|
+
const roots = opts.dirs === undefined ? [] : Array.isArray(opts.dirs) ? opts.dirs : [opts.dirs]
|
|
269
|
+
const cands: RawCandidate[] = []
|
|
270
|
+
for (const root of roots) cands.push(...candidatesFromDir(root))
|
|
271
|
+
if (opts.skills) cands.push(...candidatesFromDefs(opts.skills))
|
|
272
|
+
return cands
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Dedupe candidates by name (first-wins); later duplicates become skips. */
|
|
276
|
+
function mergeCandidates(cands: RawCandidate[]): { skills: Record<string, SkillInfo>; skipped: SkillSkip[] } {
|
|
277
|
+
const skills: Record<string, SkillInfo> = {}
|
|
278
|
+
const skipped: SkillSkip[] = []
|
|
279
|
+
for (const c of cands) {
|
|
280
|
+
if (c.skip) {
|
|
281
|
+
skipped.push(c.skip)
|
|
282
|
+
continue
|
|
283
|
+
}
|
|
284
|
+
const info = c.info!
|
|
285
|
+
if (skills[info.name]) {
|
|
286
|
+
console.warn(`[toolnexus] duplicate skill name "${info.name}" (${info.location}) — keeping first`)
|
|
287
|
+
skipped.push({ location: info.location, reason: "duplicate-name" })
|
|
288
|
+
continue
|
|
289
|
+
}
|
|
290
|
+
skills[info.name] = info
|
|
291
|
+
}
|
|
292
|
+
return { skills, skipped }
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Apply the per-agent skill allowlist (S2), semantics identical to the MCP
|
|
297
|
+
* per-server tools filter and builtins: nil/empty ⇒ all; ≥1 true ⇒ allowlist
|
|
298
|
+
* (only true-mapped names); only-false ⇒ drop-list over all-on; unknown names
|
|
299
|
+
* are ignored and warned once.
|
|
300
|
+
*/
|
|
301
|
+
function applySkillsFilter(
|
|
302
|
+
skills: Record<string, SkillInfo>,
|
|
303
|
+
filter: Record<string, boolean> | undefined,
|
|
304
|
+
): Record<string, SkillInfo> {
|
|
305
|
+
if (!filter) return skills
|
|
306
|
+
const keys = Object.keys(filter)
|
|
307
|
+
if (keys.length === 0) return skills
|
|
308
|
+
const hasTrue = keys.some((k) => filter[k] === true)
|
|
309
|
+
for (const k of keys) {
|
|
310
|
+
if (!(k in skills)) console.warn(`[toolnexus] skill filter name "${k}" matched no skill`)
|
|
311
|
+
}
|
|
312
|
+
const out: Record<string, SkillInfo> = {}
|
|
313
|
+
for (const [name, info] of Object.entries(skills)) {
|
|
314
|
+
if (hasTrue ? filter[name] === true : filter[name] !== false) out[name] = info
|
|
315
|
+
}
|
|
316
|
+
return out
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export interface SkillSource {
|
|
320
|
+
skills: Record<string, SkillInfo>
|
|
321
|
+
tool: Tool
|
|
322
|
+
/** Markdown catalog for the system prompt (mirrors opencode Skill.fmt). */
|
|
323
|
+
prompt(): string
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Discover + validate skills from the same sources loadSkills accepts, returning
|
|
328
|
+
* parsed skills plus typed skip reasons — no toolkit wired (SPEC.md §3, S3). The
|
|
329
|
+
* inventory is UNFILTERED (it exists to author/validate the allowlist).
|
|
330
|
+
*/
|
|
331
|
+
export function listSkills(input: string | string[] | LoadSkillsOptions): SkillInventory {
|
|
332
|
+
const opts: LoadSkillsOptions =
|
|
333
|
+
typeof input === "string" || Array.isArray(input) ? { dirs: input } : input
|
|
334
|
+
const merged = mergeCandidates(collectCandidates(opts))
|
|
335
|
+
return { skills: Object.values(merged.skills), skipped: merged.skipped }
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Discover skills (dirs and/or data) and build the `skill` loader tool. */
|
|
339
|
+
export function loadSkills(input: string | string[] | LoadSkillsOptions): SkillSource {
|
|
340
|
+
const opts: LoadSkillsOptions =
|
|
341
|
+
typeof input === "string" || Array.isArray(input) ? { dirs: input } : input
|
|
342
|
+
const sampleLimit = opts.sampleLimit ?? 0
|
|
343
|
+
const merged = mergeCandidates(collectCandidates(opts))
|
|
344
|
+
const skills = applySkillsFilter(merged.skills, opts.filter)
|
|
197
345
|
|
|
198
346
|
const tool: Tool = {
|
|
199
347
|
name: "skill",
|
|
@@ -214,10 +362,28 @@ export function loadSkills(dirs: string | string[]): SkillSource {
|
|
|
214
362
|
isError: true,
|
|
215
363
|
}
|
|
216
364
|
}
|
|
217
|
-
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
365
|
+
// effLimit: 0 ⇒ default 10 (byte-identical to today), n>0 ⇒ cap, -1 ⇒ omit.
|
|
366
|
+
const effLimit = sampleLimit === 0 ? 10 : sampleLimit
|
|
367
|
+
let base: string
|
|
368
|
+
let files: string[]
|
|
369
|
+
let metaDir: string
|
|
370
|
+
// Emit the <skill_files> block: always for fs skills (byte-identical, even
|
|
371
|
+
// when empty), only when a data skill actually carries resources (an
|
|
372
|
+
// instruction-only data skill omits it). Never when sampling is disabled.
|
|
373
|
+
let emitFiles = effLimit !== -1
|
|
374
|
+
if (info.origin === "logical") {
|
|
375
|
+
base = info.base ?? `skill://${info.name}/`
|
|
376
|
+
const res = info.resources ?? []
|
|
377
|
+
if (res.length === 0) emitFiles = false
|
|
378
|
+
files = effLimit > 0 ? res.slice(0, effLimit) : res
|
|
379
|
+
metaDir = base
|
|
380
|
+
} else {
|
|
381
|
+
const dir = path.dirname(info.location)
|
|
382
|
+
base = pathToFileURL(dir).href
|
|
383
|
+
files = effLimit === -1 ? [] : sampleSiblingFiles(dir, effLimit)
|
|
384
|
+
metaDir = dir
|
|
385
|
+
}
|
|
386
|
+
const lines = [
|
|
221
387
|
`<skill_content name="${info.name}">`,
|
|
222
388
|
`# Skill: ${info.name}`,
|
|
223
389
|
"",
|
|
@@ -225,14 +391,18 @@ export function loadSkills(dirs: string | string[]): SkillSource {
|
|
|
225
391
|
"",
|
|
226
392
|
`Base directory for this skill: ${base}`,
|
|
227
393
|
"Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
394
|
+
]
|
|
395
|
+
if (emitFiles) {
|
|
396
|
+
lines.push(
|
|
397
|
+
"Note: file list is sampled.",
|
|
398
|
+
"",
|
|
399
|
+
"<skill_files>",
|
|
400
|
+
files.map((f) => `<file>${f}</file>`).join("\n"),
|
|
401
|
+
"</skill_files>",
|
|
402
|
+
)
|
|
403
|
+
}
|
|
404
|
+
lines.push("</skill_content>")
|
|
405
|
+
return { output: lines.join("\n"), isError: false, metadata: { name: info.name, dir: metaDir } }
|
|
236
406
|
},
|
|
237
407
|
}
|
|
238
408
|
|
package/src/toolkit.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { type Tool, type ToolContext, type ToolResult, type McpStatus } from "./types.js"
|
|
6
6
|
import { loadMcp, type McpSource } from "./mcp.js"
|
|
7
|
-
import { loadSkills, type SkillSource } from "./skill.js"
|
|
7
|
+
import { loadSkills, type SkillDef, 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"
|
|
@@ -15,6 +15,14 @@ import type { Client } from "./client.js"
|
|
|
15
15
|
export interface ToolkitOptions {
|
|
16
16
|
mcpConfig?: string | object
|
|
17
17
|
skillsDir?: string | string[]
|
|
18
|
+
/** Skills supplied as data, bypassing the filesystem (§3, S1). */
|
|
19
|
+
skills?: SkillDef[]
|
|
20
|
+
/** Lazy provider of data-supplied skills, resolved once at toolkit build (§3, S1). */
|
|
21
|
+
skillProvider?: () => SkillDef[] | Promise<SkillDef[]>
|
|
22
|
+
/** Per-agent skill allowlist keyed on name; same semantics as the MCP tools filter (§3, S2). */
|
|
23
|
+
skillsFilter?: Record<string, boolean>
|
|
24
|
+
/** Sibling-file sample cap: 0 ⇒ default 10, n>0 ⇒ cap, -1 ⇒ omit <skill_files> (§3, S5). */
|
|
25
|
+
skillSampleLimit?: number
|
|
18
26
|
extraTools?: Tool[]
|
|
19
27
|
/** Built-in tools (§4A). On by default. false | { disabled:true } | { enabled:false } ⇒ off. */
|
|
20
28
|
builtins?: BuiltinsConfig
|
|
@@ -27,6 +35,9 @@ export interface ToolkitOptions {
|
|
|
27
35
|
* elicitation is not advertised.
|
|
28
36
|
*/
|
|
29
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
|
|
30
41
|
}
|
|
31
42
|
|
|
32
43
|
export class Toolkit {
|
|
@@ -58,8 +69,29 @@ export class Toolkit {
|
|
|
58
69
|
}
|
|
59
70
|
|
|
60
71
|
static async create(opts: ToolkitOptions): Promise<Toolkit> {
|
|
61
|
-
const mcp = opts.mcpConfig !== undefined ? await loadMcp(opts.mcpConfig, { waitFor: opts.waitFor }) : undefined
|
|
62
|
-
|
|
72
|
+
const mcp = opts.mcpConfig !== undefined ? await loadMcp(opts.mcpConfig, { waitFor: opts.waitFor, signal: opts.signal }) : undefined
|
|
73
|
+
// Skills come from directories, in-memory data, and/or a lazy provider (§3,
|
|
74
|
+
// S1). The provider is resolved here (create() is async) and merged with the
|
|
75
|
+
// data list; a provider failure is isolated so other sources still load.
|
|
76
|
+
let skill: SkillSource | undefined
|
|
77
|
+
if (opts.skillsDir !== undefined || opts.skills !== undefined || opts.skillProvider !== undefined) {
|
|
78
|
+
let providerDefs: SkillDef[] = []
|
|
79
|
+
if (opts.skillProvider) {
|
|
80
|
+
try {
|
|
81
|
+
providerDefs = await opts.skillProvider()
|
|
82
|
+
} catch (e) {
|
|
83
|
+
console.warn(`[toolnexus] skill provider failed: ${e instanceof Error ? e.message : String(e)}`)
|
|
84
|
+
providerDefs = []
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const dataDefs = [...(opts.skills ?? []), ...providerDefs]
|
|
88
|
+
skill = loadSkills({
|
|
89
|
+
dirs: opts.skillsDir,
|
|
90
|
+
skills: dataDefs.length > 0 ? dataDefs : undefined,
|
|
91
|
+
filter: opts.skillsFilter,
|
|
92
|
+
sampleLimit: opts.skillSampleLimit,
|
|
93
|
+
})
|
|
94
|
+
}
|
|
63
95
|
// The toggle comes from the `builtins` option, or a top-level `builtins`
|
|
64
96
|
// key on a parsed config object — same precedence as MCP's isEnabled.
|
|
65
97
|
let builtinsCfg = opts.builtins
|