toolnexus 0.1.2 → 0.2.1

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/src/serve.ts ADDED
@@ -0,0 +1,324 @@
1
+ /**
2
+ * A2A (agent-to-agent) — INBOUND: serve a toolkit as a remote A2A agent.
3
+ *
4
+ * `toolkit.serve(addr, { client, a2a })` stands up a minimal HTTP server that,
5
+ * when the `a2a` profile is present, exposes:
6
+ *
7
+ * GET /.well-known/agent-card.json → an Agent Card built from the toolkit's
8
+ * **skills** (SKILL.md name+description), never raw tools.
9
+ * POST / → JSON-RPC 2.0: `SendMessage` (submit a
10
+ * Task, fulfil it asynchronously via `client.run`) + `GetTask` (poll).
11
+ *
12
+ * This is the inbound counterpart to a2a.ts (outbound) and speaks the same wire
13
+ * subset (verified against a2a-python). Task persistence is a pluggable
14
+ * `TaskStore` (in-memory default; file / custom selectable via `a2a.store`).
15
+ * See ../../SPEC.md and openspec/changes/add-a2a-agents.
16
+ */
17
+ import http from "node:http"
18
+ import fs from "node:fs"
19
+ import path from "node:path"
20
+ import { randomUUID } from "node:crypto"
21
+ import { sanitize } from "./types.js"
22
+ import type { SkillInfo } from "./skill.js"
23
+ import type { RunResult } from "./client.js"
24
+ import type { A2AArtifact, A2ATask, A2ATaskState, AgentCard } from "./a2a.js"
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // TaskStore adapter — pluggable persistence for served Tasks.
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /** Pluggable Task persistence. The-factory can plug NATS/JetStream the same way. */
31
+ export interface TaskStore {
32
+ get(id: string): Promise<A2ATask | undefined>
33
+ save(task: A2ATask): Promise<void>
34
+ }
35
+
36
+ /** Default in-memory store — Tasks live only for the lifetime of the process. */
37
+ export class InMemoryTaskStore implements TaskStore {
38
+ private readonly tasks = new Map<string, A2ATask>()
39
+ async get(id: string): Promise<A2ATask | undefined> {
40
+ return this.tasks.get(id)
41
+ }
42
+ async save(task: A2ATask): Promise<void> {
43
+ this.tasks.set(task.id, task)
44
+ }
45
+ }
46
+
47
+ /** File-backed store — one JSON file per Task id, under `dir`. */
48
+ export class FileTaskStore implements TaskStore {
49
+ constructor(private readonly dir: string) {
50
+ fs.mkdirSync(dir, { recursive: true })
51
+ }
52
+ private file(id: string): string {
53
+ return path.join(this.dir, `${sanitize(id)}.json`)
54
+ }
55
+ async get(id: string): Promise<A2ATask | undefined> {
56
+ try {
57
+ return JSON.parse(fs.readFileSync(this.file(id), "utf8")) as A2ATask
58
+ } catch {
59
+ return undefined
60
+ }
61
+ }
62
+ async save(task: A2ATask): Promise<void> {
63
+ fs.writeFileSync(this.file(task.id), JSON.stringify(task))
64
+ }
65
+ }
66
+
67
+ /** Map a `store` selector to a concrete TaskStore. `"file:<dir>"` → FileTaskStore. */
68
+ export function resolveStore(store?: TaskStore | "memory" | string): TaskStore {
69
+ if (store === undefined || store === "memory") return new InMemoryTaskStore()
70
+ if (typeof store === "string") {
71
+ if (store.startsWith("file:")) return new FileTaskStore(store.slice("file:".length))
72
+ throw new Error(`Unknown A2A store: "${store}" (expected "memory" or "file:<dir>")`)
73
+ }
74
+ return store
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // A2A serve config + handle.
79
+ // ---------------------------------------------------------------------------
80
+
81
+ export interface A2AProvider {
82
+ organization: string
83
+ url: string
84
+ }
85
+
86
+ /** Opt-in A2A profile for `toolkit.serve` — configures the Agent Card + store. */
87
+ export interface A2AConfig {
88
+ name?: string
89
+ description?: string
90
+ version?: string
91
+ provider?: A2AProvider
92
+ /** Subset of the toolkit's skill ids/names to advertise; omit ⇒ all. */
93
+ skills?: string[]
94
+ /** `"memory"` (default) | `"file:<dir>"` | a custom TaskStore. */
95
+ store?: TaskStore | "memory" | string
96
+ }
97
+
98
+ /** Surfaced on each Task's terminal state, carrying the RunResult telemetry. */
99
+ export interface OnTaskEvent {
100
+ id: string
101
+ skill?: string
102
+ task: A2ATask
103
+ result?: RunResult
104
+ state: A2ATaskState
105
+ }
106
+
107
+ export type OnTask = (ev: OnTaskEvent) => void | Promise<void>
108
+
109
+ /** Handle returned by `toolkit.serve` — the base URL plus a stop/close method. */
110
+ export interface ServeHandle {
111
+ /** Base URL of the server, e.g. `http://127.0.0.1:PORT`. */
112
+ url: string
113
+ stop(): Promise<void>
114
+ /** Alias for `stop()`. */
115
+ close(): Promise<void>
116
+ }
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // Card builder.
120
+ // ---------------------------------------------------------------------------
121
+
122
+ const PROTOCOL_VERSION = "0.3.0"
123
+ const DEFAULT_VERSION = "0.1.0"
124
+
125
+ /**
126
+ * Build an Agent Card from the toolkit's skills. `skills` are the SkillSource's
127
+ * `SkillInfo`s (never raw tools); filtered to `cfg.skills` when given. `url` is
128
+ * the JSON-RPC endpoint peers POST to.
129
+ */
130
+ export function buildAgentCard(cfg: A2AConfig, skills: SkillInfo[], url: string): AgentCard {
131
+ const wanted = cfg.skills
132
+ const selected = wanted ? skills.filter((s) => wanted.includes(s.name)) : skills
133
+ const card: AgentCard = {
134
+ name: cfg.name ?? "toolnexus-agent",
135
+ description: cfg.description ?? "",
136
+ version: cfg.version ?? DEFAULT_VERSION,
137
+ protocolVersion: PROTOCOL_VERSION,
138
+ capabilities: { streaming: false, pushNotifications: false },
139
+ defaultInputModes: ["text"],
140
+ defaultOutputModes: ["text"],
141
+ skills: selected.map((s) => ({ id: s.name, name: s.name, description: s.description ?? "" })),
142
+ url,
143
+ }
144
+ if (cfg.provider) card.provider = cfg.provider
145
+ return card
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // Server.
150
+ // ---------------------------------------------------------------------------
151
+
152
+ const CARD_PATH = "/.well-known/agent-card.json"
153
+
154
+ export interface StartServerOptions {
155
+ /** `host:port` (port 0 ⇒ ephemeral). */
156
+ addr: string
157
+ /** When present, mounts the A2A card + JSON-RPC routes; absent ⇒ no A2A routes. */
158
+ a2a?: A2AConfig
159
+ /** The toolkit's skills, for the card. */
160
+ skills: SkillInfo[]
161
+ /** Runs one served Task through the client loop. */
162
+ runTask: (text: string) => Promise<RunResult>
163
+ onTask?: OnTask
164
+ }
165
+
166
+ /** Extract the concatenated text of a JSON-RPC `SendMessage` message's parts. */
167
+ function messageText(params: any): string {
168
+ const parts = params?.message?.parts
169
+ if (!Array.isArray(parts)) return ""
170
+ return parts
171
+ .filter((p: any) => p && p.kind === "text" && typeof p.text === "string")
172
+ .map((p: any) => p.text)
173
+ .join("")
174
+ }
175
+
176
+ function readBody(req: http.IncomingMessage): Promise<string> {
177
+ return new Promise((resolve) => {
178
+ let body = ""
179
+ req.on("data", (c) => (body += c))
180
+ req.on("end", () => resolve(body))
181
+ })
182
+ }
183
+
184
+ /**
185
+ * Start the HTTP server. Delegated to by `Toolkit.serve`. When `a2a` is absent,
186
+ * the server answers 404 to everything (a minimal base for now).
187
+ */
188
+ export async function startA2AServer(opts: StartServerOptions): Promise<ServeHandle> {
189
+ const { a2a, skills, runTask, onTask } = opts
190
+ const [host, portStr] = splitAddr(opts.addr)
191
+ const store = a2a ? resolveStore(a2a.store) : undefined
192
+
193
+ const server = http.createServer((req, res) => {
194
+ const rpcRes = (id: unknown, payload: object) => {
195
+ res.writeHead(200, { "content-type": "application/json" })
196
+ res.end(JSON.stringify({ jsonrpc: "2.0", id: id ?? null, ...payload }))
197
+ }
198
+ const rpcOk = (id: unknown, result: unknown) => rpcRes(id, { result })
199
+ const rpcErr = (id: unknown, code: number, message: string) => rpcRes(id, { error: { code, message } })
200
+
201
+ // No A2A profile ⇒ no routes.
202
+ if (!a2a || !store) {
203
+ res.writeHead(404)
204
+ res.end("not found")
205
+ return
206
+ }
207
+
208
+ if (req.method === "GET" && req.url === CARD_PATH) {
209
+ const url = baseUrl(host, server) + "/"
210
+ res.writeHead(200, { "content-type": "application/json" })
211
+ res.end(JSON.stringify(buildAgentCard(a2a, skills, url)))
212
+ return
213
+ }
214
+
215
+ if (req.method === "POST") {
216
+ readBody(req).then((body) => {
217
+ let rpc: any
218
+ try {
219
+ rpc = JSON.parse(body)
220
+ } catch {
221
+ return rpcErr(null, -32700, "Parse error")
222
+ }
223
+ if (rpc?.method === "SendMessage") {
224
+ const text = messageText(rpc.params)
225
+ const id = randomUUID()
226
+ const submitted: A2ATask = { id, status: { state: "submitted" } }
227
+ // Persist submitted, kick fulfilment async, return the id immediately.
228
+ store.save(submitted).then(() => {
229
+ void fulfil(id, text, store, runTask, onTask)
230
+ rpcOk(rpc.id, submitted)
231
+ })
232
+ return
233
+ }
234
+ if (rpc?.method === "GetTask") {
235
+ const id = String(rpc.params?.id ?? "")
236
+ store.get(id).then((task) => {
237
+ if (!task) return rpcErr(rpc.id, -32001, `Task not found: ${id}`)
238
+ rpcOk(rpc.id, task)
239
+ })
240
+ return
241
+ }
242
+ return rpcErr(rpc?.id ?? null, -32601, `Method not found: ${rpc?.method}`)
243
+ })
244
+ return
245
+ }
246
+
247
+ res.writeHead(404)
248
+ res.end("not found")
249
+ })
250
+
251
+ await new Promise<void>((resolve) => server.listen(Number(portStr), host, resolve))
252
+
253
+ return {
254
+ url: baseUrl(host, server),
255
+ stop() {
256
+ return new Promise<void>((resolve) => server.close(() => resolve()))
257
+ },
258
+ close() {
259
+ return new Promise<void>((resolve) => server.close(() => resolve()))
260
+ },
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Background fulfilment: submitted → working → (completed | failed). Never
266
+ * throws — a fulfilment error becomes a `failed` Task, keeping the server alive.
267
+ */
268
+ async function fulfil(
269
+ id: string,
270
+ text: string,
271
+ store: TaskStore,
272
+ runTask: (text: string) => Promise<RunResult>,
273
+ onTask?: OnTask,
274
+ ): Promise<void> {
275
+ let task: A2ATask
276
+ let result: RunResult | undefined
277
+ let state: A2ATaskState
278
+ try {
279
+ await store.save({ id, status: { state: "working" } })
280
+ result = await runTask(text)
281
+ const artifact: A2AArtifact = {
282
+ artifactId: randomUUID(),
283
+ parts: [{ kind: "text", text: result.text }],
284
+ }
285
+ task = { id, status: { state: "completed" }, artifacts: [artifact] }
286
+ state = "completed"
287
+ } catch (e) {
288
+ const detail = e instanceof Error ? e.message : String(e)
289
+ task = { id, status: { state: "failed", message: { role: "agent", parts: [{ kind: "text", text: detail }] } } }
290
+ state = "failed"
291
+ }
292
+ try {
293
+ await store.save(task)
294
+ } catch {
295
+ // A store write failure must not crash the server.
296
+ }
297
+ if (onTask) {
298
+ try {
299
+ await onTask({ id, task, result, state })
300
+ } catch {
301
+ // Host callback errors are isolated.
302
+ }
303
+ }
304
+ }
305
+
306
+ // ---------------------------------------------------------------------------
307
+ // Address helpers.
308
+ // ---------------------------------------------------------------------------
309
+
310
+ /** Split `host:port` on the last colon. Missing host ⇒ 127.0.0.1. */
311
+ function splitAddr(addr: string): [string, string] {
312
+ const idx = addr.lastIndexOf(":")
313
+ if (idx === -1) return ["127.0.0.1", addr]
314
+ const host = addr.slice(0, idx) || "127.0.0.1"
315
+ return [host, addr.slice(idx + 1)]
316
+ }
317
+
318
+ /** Base URL for a listening server; 0.0.0.0 is reported as 127.0.0.1 for callers. */
319
+ function baseUrl(host: string, server: http.Server): string {
320
+ const addr = server.address()
321
+ const port = typeof addr === "object" && addr ? addr.port : 0
322
+ const h = host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host
323
+ return `http://${h}:${port}`
324
+ }
package/src/skill.ts CHANGED
@@ -4,9 +4,10 @@
4
4
  * loads a skill's instructions + sampled resources on demand (progressive
5
5
  * disclosure).
6
6
  */
7
- import { readFileSync, readdirSync, statSync } from "node:fs"
7
+ import { readFileSync, readdirSync, realpathSync, statSync } from "node:fs"
8
8
  import path from "node:path"
9
9
  import { pathToFileURL } from "node:url"
10
+ import { parse as parseYaml } from "yaml"
10
11
  import type { Tool, ToolResult } from "./types.js"
11
12
 
12
13
  export const SKILL_TOOL_DESCRIPTION = `Load a specialized skill when the task at hand matches one of the skills listed in the system prompt.
@@ -15,6 +16,14 @@ Use this tool to inject the skill's instructions and resources into current conv
15
16
 
16
17
  The skill name must match one of the skills listed in your system prompt.`
17
18
 
19
+ /**
20
+ * Instruction preamble prepended to skillsPrompt() when ≥1 described skill
21
+ * exists. Byte-identical across all four ports — do not reword. See SPEC.md §3.
22
+ */
23
+ export const SKILLS_PROMPT_PREAMBLE =
24
+ "Skills provide specialized instructions and workflows for specific tasks.\n" +
25
+ "Use the skill tool to load a skill when a task matches its description."
26
+
18
27
  export interface SkillInfo {
19
28
  name: string
20
29
  description?: string
@@ -22,23 +31,29 @@ export interface SkillInfo {
22
31
  content: string // body after frontmatter
23
32
  }
24
33
 
25
- /** Minimal YAML frontmatter parser — only flat `key: value` pairs are needed. */
34
+ /**
35
+ * Parse YAML frontmatter (between the leading `---` fences) with a real YAML
36
+ * parser, so folded (`>`)/literal (`|`) block scalars, quoting, and multi-line
37
+ * values all resolve correctly. Scalar values are coerced to strings.
38
+ */
26
39
  function parseFrontmatter(text: string): { data: Record<string, string>; content: string } {
27
40
  const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text)
28
41
  if (!match) return { data: {}, content: text }
29
42
  const data: Record<string, string> = {}
30
- for (const line of match[1].split(/\r?\n/)) {
31
- const idx = line.indexOf(":")
32
- if (idx === -1) continue
33
- const key = line.slice(0, idx).trim()
34
- let value = line.slice(idx + 1).trim()
35
- if (
36
- (value.startsWith('"') && value.endsWith('"')) ||
37
- (value.startsWith("'") && value.endsWith("'"))
38
- ) {
39
- value = value.slice(1, -1)
43
+ let parsed: unknown
44
+ try {
45
+ parsed = parseYaml(match[1])
46
+ } catch {
47
+ parsed = null
48
+ }
49
+ if (parsed && typeof parsed === "object") {
50
+ for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
51
+ // Trim so block-scalar trailing newlines (chomping differs subtly between
52
+ // YAML libs) don't leak — keeps the five ports byte-identical.
53
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
54
+ data[key] = String(value).trim()
55
+ }
40
56
  }
41
- if (key) data[key] = value
42
57
  }
43
58
  return { data, content: match[2] }
44
59
  }
@@ -46,6 +61,9 @@ function parseFrontmatter(text: string): { data: Record<string, string>; content
46
61
  function walkSkillFiles(root: string): string[] {
47
62
  const out: string[] = []
48
63
  const stack = [root]
64
+ // Follow symlinked directories (like opencode's `symlink: true` glob); guard
65
+ // against symlink cycles by tracking resolved real paths already visited.
66
+ const seen = new Set<string>()
49
67
  while (stack.length) {
50
68
  const dir = stack.pop()!
51
69
  let entries
@@ -56,10 +74,30 @@ function walkSkillFiles(root: string): string[] {
56
74
  }
57
75
  for (const entry of entries) {
58
76
  const full = path.join(dir, entry.name)
59
- if (entry.isDirectory()) {
77
+ let isDir = entry.isDirectory()
78
+ let isFile = entry.isFile()
79
+ if (entry.isSymbolicLink()) {
80
+ // Resolve the link target to decide whether to descend / collect it.
81
+ try {
82
+ const st = statSync(full)
83
+ isDir = st.isDirectory()
84
+ isFile = st.isFile()
85
+ } catch {
86
+ continue
87
+ }
88
+ }
89
+ if (isDir) {
60
90
  if (entry.name === "node_modules" || entry.name === ".git") continue
91
+ let real
92
+ try {
93
+ real = realpathSync(full)
94
+ } catch {
95
+ continue
96
+ }
97
+ if (seen.has(real)) continue
98
+ seen.add(real)
61
99
  stack.push(full)
62
- } else if (entry.isFile() && entry.name === "SKILL.md") {
100
+ } else if (isFile && entry.name === "SKILL.md") {
63
101
  out.push(full)
64
102
  }
65
103
  }
@@ -70,6 +108,7 @@ function walkSkillFiles(root: string): string[] {
70
108
  function sampleSiblingFiles(dir: string, limit = 10): string[] {
71
109
  const out: string[] = []
72
110
  const stack = [dir]
111
+ const seen = new Set<string>()
73
112
  while (stack.length && out.length < limit) {
74
113
  const cur = stack.pop()!
75
114
  let entries
@@ -81,10 +120,29 @@ function sampleSiblingFiles(dir: string, limit = 10): string[] {
81
120
  for (const entry of entries) {
82
121
  if (out.length >= limit) break
83
122
  const full = path.join(cur, entry.name)
84
- if (entry.isDirectory()) {
123
+ let isDir = entry.isDirectory()
124
+ let isFile = entry.isFile()
125
+ if (entry.isSymbolicLink()) {
126
+ try {
127
+ const st = statSync(full)
128
+ isDir = st.isDirectory()
129
+ isFile = st.isFile()
130
+ } catch {
131
+ continue
132
+ }
133
+ }
134
+ if (isDir) {
85
135
  if (entry.name === "node_modules" || entry.name === ".git") continue
136
+ let real
137
+ try {
138
+ real = realpathSync(full)
139
+ } catch {
140
+ continue
141
+ }
142
+ if (seen.has(real)) continue
143
+ seen.add(real)
86
144
  stack.push(full)
87
- } else if (entry.isFile() && entry.name !== "SKILL.md") {
145
+ } else if (isFile && entry.name !== "SKILL.md") {
88
146
  out.push(full)
89
147
  }
90
148
  }
@@ -187,6 +245,8 @@ export function loadSkills(dirs: string | string[]): SkillSource {
187
245
  .sort((a, b) => a.name.localeCompare(b.name))
188
246
  if (described.length === 0) return "No skills are currently available."
189
247
  return [
248
+ SKILLS_PROMPT_PREAMBLE,
249
+ "",
190
250
  "## Available Skills",
191
251
  ...described.map((s) => `- **${s.name}**: ${s.description}`),
192
252
  ].join("\n")
package/src/toolkit.ts CHANGED
@@ -5,12 +5,20 @@
5
5
  import { type Tool, type ToolContext, type ToolResult, type McpStatus } from "./types.js"
6
6
  import { loadMcp, type McpSource } from "./mcp.js"
7
7
  import { loadSkills, type SkillSource } from "./skill.js"
8
+ import { selectBuiltins, type BuiltinsConfig } from "./builtin.js"
9
+ import { agentTools, parseAgentsConfig, type Agent } from "./a2a.js"
10
+ import { startA2AServer, type A2AConfig, type OnTask, type ServeHandle } from "./serve.js"
8
11
  import { toAnthropic, toGemini, toOpenAI } from "./adapters.js"
12
+ import type { Client } from "./client.js"
9
13
 
10
14
  export interface ToolkitOptions {
11
15
  mcpConfig?: string | object
12
16
  skillsDir?: string | string[]
13
17
  extraTools?: Tool[]
18
+ /** Built-in tools (§4A). On by default. false | { disabled:true } | { enabled:false } ⇒ off. */
19
+ builtins?: BuiltinsConfig
20
+ /** Remote A2A agents — each advertised skill becomes a tool (source:"a2a"). */
21
+ agents?: Agent[]
14
22
  }
15
23
 
16
24
  export class Toolkit {
@@ -18,9 +26,18 @@ export class Toolkit {
18
26
  private constructor(
19
27
  private readonly mcp: McpSource | undefined,
20
28
  private readonly skill: SkillSource | undefined,
29
+ builtins: Tool[],
30
+ agents: Tool[],
21
31
  extraTools: Tool[],
32
+ /** A2A profile read from a top-level `a2a` config block (serve() falls back to this). */
33
+ private readonly a2aConfig: A2AConfig | undefined,
22
34
  ) {
23
- const all = [...(mcp?.tools ?? []), ...(skill ? [skill.tool] : []), ...extraTools]
35
+ // Builtins are the lowest-precedence source: a host extraTools entry with
36
+ // the same name shadows a builtin (SPEC §4). Drop shadowed builtins up
37
+ // front, then apply the normal first-wins dedupe for MCP/skill/agents/extras.
38
+ const extraNames = new Set(extraTools.map((t) => t.name))
39
+ const activeBuiltins = builtins.filter((b) => !extraNames.has(b.name))
40
+ const all = [...(mcp?.tools ?? []), ...(skill ? [skill.tool] : []), ...activeBuiltins, ...agents, ...extraTools]
24
41
  for (const t of all) {
25
42
  if (this.byName.has(t.name)) {
26
43
  console.warn(`[toolnexus] duplicate tool name "${t.name}" — keeping first`)
@@ -33,7 +50,44 @@ export class Toolkit {
33
50
  static async create(opts: ToolkitOptions): Promise<Toolkit> {
34
51
  const mcp = opts.mcpConfig !== undefined ? await loadMcp(opts.mcpConfig) : undefined
35
52
  const skill = opts.skillsDir !== undefined ? loadSkills(opts.skillsDir) : undefined
36
- return new Toolkit(mcp, skill, opts.extraTools ?? [])
53
+ // The toggle comes from the `builtins` option, or a top-level `builtins`
54
+ // key on a parsed config object — same precedence as MCP's isEnabled.
55
+ let builtinsCfg = opts.builtins
56
+ if (
57
+ builtinsCfg === undefined &&
58
+ opts.mcpConfig &&
59
+ typeof opts.mcpConfig === "object" &&
60
+ "builtins" in opts.mcpConfig
61
+ ) {
62
+ builtinsCfg = (opts.mcpConfig as { builtins?: BuiltinsConfig }).builtins
63
+ }
64
+ const builtins = selectBuiltins(builtinsCfg)
65
+
66
+ // Remote A2A agents come from the `agents:[...]` option plus a top-level
67
+ // `agents` block on a parsed config object (mirrors mcpServers). Each is
68
+ // resolved to its skill tools; a failing agent never breaks the toolkit.
69
+ const agentDescriptors: Agent[] = [...(opts.agents ?? [])]
70
+ if (opts.mcpConfig && typeof opts.mcpConfig === "object" && "agents" in opts.mcpConfig) {
71
+ agentDescriptors.push(...parseAgentsConfig((opts.mcpConfig as { agents?: Parameters<typeof parseAgentsConfig>[0] }).agents))
72
+ }
73
+ const agentToolLists = await Promise.all(
74
+ agentDescriptors.map((a) =>
75
+ agentTools(a).catch((e) => {
76
+ console.warn(`[toolnexus] A2A agent "${a.card}" failed: ${e instanceof Error ? e.message : String(e)}`)
77
+ return [] as Tool[]
78
+ }),
79
+ ),
80
+ )
81
+ const agentToolsFlat = agentToolLists.flat()
82
+
83
+ // A2A inbound profile: a top-level `a2a` block on a parsed config object
84
+ // (mirrors builtins/agents). serve() prefers its inline `a2a` over this.
85
+ let a2aConfig: A2AConfig | undefined
86
+ if (opts.mcpConfig && typeof opts.mcpConfig === "object" && "a2a" in opts.mcpConfig) {
87
+ a2aConfig = (opts.mcpConfig as { a2a?: A2AConfig }).a2a
88
+ }
89
+
90
+ return new Toolkit(mcp, skill, builtins, agentToolsFlat, opts.extraTools ?? [], a2aConfig)
37
91
  }
38
92
 
39
93
  tools(): Tool[] {
@@ -56,6 +110,35 @@ export class Toolkit {
56
110
  return this
57
111
  }
58
112
 
113
+ /**
114
+ * Fetch a remote A2A agent's card at runtime and register its skills as tools.
115
+ * Accepts an Agent descriptor or a bare card URL. First-name-wins dedupe.
116
+ */
117
+ async addAgent(agentOrCardUrl: Agent | string, opts?: Omit<Agent, "card">): Promise<this> {
118
+ const ag: Agent = typeof agentOrCardUrl === "string" ? { card: agentOrCardUrl, ...opts } : agentOrCardUrl
119
+ const tools = await agentTools(ag)
120
+ return this.register(...tools)
121
+ }
122
+
123
+ /**
124
+ * Serve this toolkit as an agent over HTTP. When the `a2a` profile is present
125
+ * (inline, or a top-level `a2a` config block the toolkit was built from), it
126
+ * mounts the A2A Agent Card (`/.well-known/agent-card.json`, built from skills)
127
+ * and a JSON-RPC endpoint (`SendMessage` + `GetTask`) fulfilled by `client.run`.
128
+ * When `a2a` is absent, no A2A routes are mounted. Returns a stoppable handle.
129
+ */
130
+ serve(addr: string, opts: { client: Client; a2a?: A2AConfig; onTask?: OnTask }): Promise<ServeHandle> {
131
+ const a2a = opts.a2a ?? this.a2aConfig
132
+ const skills = this.skill ? Object.values(this.skill.skills) : []
133
+ return startA2AServer({
134
+ addr,
135
+ a2a,
136
+ skills,
137
+ runTask: (text) => opts.client.run(text, { toolkit: this }),
138
+ onTask: opts.onTask,
139
+ })
140
+ }
141
+
59
142
  async execute(name: string, args: Record<string, unknown>, ctx?: ToolContext): Promise<ToolResult> {
60
143
  const tool = this.byName.get(name)
61
144
  if (!tool) {
package/src/types.ts CHANGED
@@ -10,7 +10,7 @@ export type JSONSchema = {
10
10
  [k: string]: unknown
11
11
  }
12
12
 
13
- export type ToolSource = "mcp" | "skill" | "native" | "http" | "custom"
13
+ export type ToolSource = "mcp" | "skill" | "builtin" | "native" | "http" | "a2a" | "custom"
14
14
 
15
15
  export interface ToolResult {
16
16
  output: string