opencode-mcp-triage 0.7.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/src/index.ts ADDED
@@ -0,0 +1,425 @@
1
+ /*
2
+ * opencode-mcp-triage — Subagent Router Plugin
3
+ * ==============================================
4
+ * Version: 0.6.1
5
+ * License: MIT
6
+ *
7
+ * Routes MCP work to scoped subagents. On first run, automatically
8
+ * disables all MCP tools globally in opencode.jsonc so they don't
9
+ * consume tokens in the main agent. Subagents re-enable specific
10
+ * servers via tool scoping.
11
+ *
12
+ * How it works:
13
+ * 1. Plugin init: reads MCP servers + subagents from config
14
+ * 2. Writes "servername_*": false to project config (disables MCP tools in main session)
15
+ * 3. Exposes triage_mcp tool: scores user query against subagent names/descriptions/MCP names
16
+ * 4. Returns best-matching subagent — user invokes it via @name or Task tool
17
+ *
18
+ * Token savings: MCP tools have large descriptions. Disabling them in the
19
+ * main session saves ~80% of MCP-related tokens. Subagents only carry
20
+ * their scoped servers' tools.
21
+ *
22
+ * Install: { "plugin": ["opencode-mcp-triage"] } in opencode.jsonc
23
+ * Docs: https://github.com/cascharly/opencode-mcp-triage
24
+ */
25
+
26
+ import type { Plugin } from "@opencode-ai/plugin"
27
+ import { tool } from "@opencode-ai/plugin"
28
+ import type { McpServer, Subagent } from "./types.js"
29
+ import { readMcpConfig, readSubagentConfig } from "./config.js"
30
+ import { scoreSubagents, THRESHOLD } from "./triage.js"
31
+ import { ensureToolsDisabled, ensureSubagentsCreated } from "./writer.js"
32
+
33
+ /** Cache TTL: 5 seconds — balances freshness with performance */
34
+ const CACHE_TTL_MS = 5000
35
+
36
+ /** Reload debounce: 1 second cooldown to prevent spam */
37
+ const RELOAD_COOLDOWN_MS = 1000
38
+
39
+ /**
40
+ * Mutable plugin state — updated on init and reload.
41
+ *
42
+ * mcpServers: all MCP servers from config (name + description)
43
+ * subagents: agents with MCP tool scoping
44
+ * mcpNames: flat list of MCP server names (for display)
45
+ * assignedMcps: set of MCP names that have at least one subagent
46
+ */
47
+ interface State {
48
+ mcpServers: McpServer[]
49
+ subagents: Subagent[]
50
+ mcpNames: string[]
51
+ assignedMcps: Set<string>
52
+ }
53
+
54
+ /**
55
+ * Generic cache with TTL.
56
+ * Stores a value with an expiry timestamp. Returns null if expired.
57
+ */
58
+ interface CacheEntry<T> {
59
+ value: T
60
+ expiresAt: number
61
+ }
62
+
63
+ interface Cache<T> {
64
+ get(): T | null
65
+ set(value: T): void
66
+ invalidate(): void
67
+ }
68
+
69
+ function createCache<T>(ttlMs: number): Cache<T> {
70
+ let entry: CacheEntry<T> | null = null
71
+
72
+ return {
73
+ get(): T | null {
74
+ if (entry && Date.now() < entry.expiresAt) {
75
+ return entry.value
76
+ }
77
+ return null
78
+ },
79
+ set(value: T) {
80
+ entry = { value, expiresAt: Date.now() + ttlMs }
81
+ },
82
+ invalidate() {
83
+ entry = null
84
+ },
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Builds state from raw config data.
90
+ *
91
+ * assignedMcps tracks which MCP servers are covered by subagents.
92
+ * Used to report unassigned servers (no subagent handles them).
93
+ */
94
+ function buildState(mcpServers: McpServer[], subagents: Subagent[]): State {
95
+ const assignedMcps = new Set<string>()
96
+ for (const sa of subagents) {
97
+ for (const m of sa.mcpServers) {
98
+ assignedMcps.add(m)
99
+ }
100
+ }
101
+ return {
102
+ mcpServers,
103
+ subagents,
104
+ mcpNames: mcpServers.map((s) => s.name),
105
+ assignedMcps,
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Sends a TUI toast notification via OpenCode client.
111
+ * Gracefully handles missing client.tui (older OpenCode versions).
112
+ */
113
+ function showToast(
114
+ client: unknown,
115
+ message: string,
116
+ variant: "success" | "info" | "error" = "info"
117
+ ) {
118
+ try {
119
+ const c = client as { tui?: { showToast?: (...args: unknown[]) => unknown } }
120
+ c.tui?.showToast?.({ message, variant })
121
+ } catch {
122
+ // TUI not available — silently skip
123
+ }
124
+ }
125
+
126
+ /**
127
+ * OpenCode plugin entry point.
128
+ *
129
+ * Runs once when OpenCode starts. Must complete before returning —
130
+ * ensureToolsDisabled writes to config file and must finish before
131
+ * the main agent starts using tools.
132
+ *
133
+ * Returns two tools:
134
+ * - triage_mcp: routes queries to the best subagent
135
+ * - mcp_stats: shows routing status and token savings
136
+ */
137
+ export const server: Plugin = async ({ directory, client }) => {
138
+ // Config caches with 5s TTL — picks up CLI toggles without restart
139
+ const mcpCache = createCache<McpServer[]>(CACHE_TTL_MS)
140
+ const subagentCache = createCache<Subagent[]>(CACHE_TTL_MS)
141
+
142
+ // Reload debounce: track last reload time to prevent spam
143
+ let lastReloadAt = 0
144
+
145
+ async function getCachedMcpServers(): Promise<McpServer[]> {
146
+ const cached = mcpCache.get()
147
+ if (cached) return cached
148
+ const result = await readMcpConfig(directory)
149
+ mcpCache.set(result)
150
+ return result
151
+ }
152
+
153
+ async function getCachedSubagents(): Promise<Subagent[]> {
154
+ const cached = subagentCache.get()
155
+ if (cached) return cached
156
+ const result = await readSubagentConfig(directory)
157
+ subagentCache.set(result)
158
+ return result
159
+ }
160
+
161
+ const mcpServers = await getCachedMcpServers()
162
+ const mcpNames = mcpServers.map((s) => s.name)
163
+
164
+ // Phase 1: disable all MCP tools in main agent
165
+ // This MUST complete before returning — otherwise main session
166
+ // could use MCP tools before they're disabled
167
+ await ensureToolsDisabled(directory, mcpNames)
168
+
169
+ // Phase 2: read current subagents, then auto-create for unassigned MCPs
170
+ let subagents = await getCachedSubagents()
171
+ const created = await ensureSubagentsCreated(directory, mcpServers, subagents)
172
+ if (created > 0) {
173
+ // Re-read after auto-create so state is accurate
174
+ subagentCache.invalidate()
175
+ subagents = await getCachedSubagents()
176
+ }
177
+
178
+ const state = buildState(mcpServers, subagents)
179
+
180
+ return {
181
+ tool: {
182
+ /**
183
+ * Triage Tool: matches a user query to the best subagent.
184
+ *
185
+ * Scoring uses keyword matching against subagent name, description,
186
+ * and assigned MCP server names. Returns the top match if the score
187
+ * gap exceeds THRESHOLD, otherwise shows multiple options.
188
+ *
189
+ * Special queries:
190
+ * - "reload": re-reads config without restarting OpenCode
191
+ * - "": lists all available subagents
192
+ */
193
+ triage_mcp: tool({
194
+ description:
195
+ "Discover and route to the right MCP subagent. " +
196
+ "Call this before any non-trivial MCP task. " +
197
+ "Pass a short description of what you need. " +
198
+ "Returns the best matching subagent and its available MCP tools. " +
199
+ "Use query 'reload' to re-read MCP config without restarting.",
200
+ args: {
201
+ query: tool.schema
202
+ .string()
203
+ .describe(
204
+ "What you want to do — e.g. 'search library docs', " +
205
+ "'manage GitHub issues', 'database operations', " +
206
+ "or 'reload' to refresh MCP config"
207
+ ),
208
+ },
209
+ async execute(args, context) {
210
+ // Abort signal handling — check if request was cancelled
211
+ if (context?.abort?.aborted) {
212
+ return "Triage cancelled."
213
+ }
214
+
215
+ const query = args.query.trim()
216
+
217
+ // "reload" — re-read config files without restarting
218
+ if (query.toLowerCase() === "reload") {
219
+ // Debounce: prevent spam reloads
220
+ const now = Date.now()
221
+ if (now - lastReloadAt < RELOAD_COOLDOWN_MS) {
222
+ return "Reload cooldown active. Try again in a moment."
223
+ }
224
+ lastReloadAt = now
225
+
226
+ mcpCache.invalidate()
227
+ subagentCache.invalidate()
228
+ state.mcpServers = await getCachedMcpServers()
229
+ let sa = await getCachedSubagents()
230
+ const created = await ensureSubagentsCreated(
231
+ directory,
232
+ state.mcpServers,
233
+ sa
234
+ )
235
+ if (created > 0) {
236
+ subagentCache.invalidate()
237
+ sa = await getCachedSubagents()
238
+ }
239
+ state.subagents = sa
240
+ const fresh = buildState(state.mcpServers, state.subagents)
241
+ Object.assign(state, fresh)
242
+ const lines = ["MCP config reloaded."]
243
+ if (created > 0) {
244
+ lines.push(`Auto-created ${created} subagent(s) for new MCP(s).`)
245
+ }
246
+ lines.push(
247
+ `Subagents: ${state.subagents.map((s) => s.name).join(", ") || "none"}`
248
+ )
249
+ lines.push(
250
+ `MCP servers: ${state.mcpServers.map((s) => s.name).join(", ") || "none"}`
251
+ )
252
+ showToast(client, "MCP config reloaded", "success")
253
+ return lines.join("\n")
254
+ }
255
+
256
+ // Empty query — list all subagents
257
+ if (!query) {
258
+ if (state.subagents.length === 0) {
259
+ return [
260
+ "No MCP subagents configured.",
261
+ "",
262
+ "Add subagents in opencode.jsonc under 'agent' section with tool scoping.",
263
+ ].join("\n")
264
+ }
265
+ const lines = ["Available subagents:"]
266
+ for (const sa of state.subagents) {
267
+ const mcps = sa.mcpServers.join(", ")
268
+ lines.push(
269
+ ` @${sa.name} — ${sa.description || "no description"}${mcps ? ` [${mcps}]` : ""}`
270
+ )
271
+ }
272
+ lines.push("")
273
+ lines.push("Use @agent-name in your message to invoke a subagent.")
274
+ return lines.join("\n")
275
+ }
276
+
277
+ // No subagents configured — show setup instructions
278
+ if (state.subagents.length === 0) {
279
+ return [
280
+ "No MCP subagents configured.",
281
+ "",
282
+ "Add subagents in opencode.jsonc:",
283
+ ` "agent": { "myserver": { "mode": "subagent", "description": "...", "tools": { "myserver_*": true } } }`,
284
+ ].join("\n")
285
+ }
286
+
287
+ // Score and rank subagents
288
+ const scored = scoreSubagents(query, state.subagents)
289
+ .filter((s) => s.score > 0)
290
+ .sort((a, b) => b.score - a.score)
291
+
292
+ // No matches — show available options
293
+ if (scored.length === 0) {
294
+ const names = state.subagents.map((s) => s.name).join(", ")
295
+ showToast(client, `No match for "${query}"`, "error")
296
+ return [
297
+ `No subagent matches "${query}".`,
298
+ `Available: ${names}`,
299
+ "",
300
+ "Try broader keywords or call triage_mcp with empty query to list all.",
301
+ ].join("\n")
302
+ }
303
+
304
+ // Check if top match is clearly better than runner-up
305
+ const gap = scored[0].score - (scored[1]?.score ?? 0)
306
+
307
+ if (gap >= THRESHOLD || scored.length === 1) {
308
+ // Clear winner — route to it
309
+ const match = scored[0]
310
+ const mcps = match.subagent.mcpServers.join(", ")
311
+ const lines = [
312
+ `ROUTED: @${match.subagent.name}`,
313
+ match.subagent.description
314
+ ? ` ${match.subagent.description}`
315
+ : "",
316
+ mcps ? ` MCP: ${mcps}` : "",
317
+ ` Matched by: ${match.matchedBy}`,
318
+ "",
319
+ `Invoke with @${match.subagent.name} in your message, or use the Task tool:`,
320
+ ` task({ subagent_type: "${match.subagent.name}", prompt: "..." })`,
321
+ ]
322
+ showToast(client, `Routed to @${match.subagent.name}`, "success")
323
+ return lines.join("\n")
324
+ }
325
+
326
+ // Too close to call — show top 5 options
327
+ const top = scored.slice(0, 5)
328
+ const lines = [`Multiple subagents match "${query}":`, ""]
329
+ top.forEach((s, i) => {
330
+ const mcps = s.subagent.mcpServers.join(", ")
331
+ lines.push(
332
+ ` ${i + 1}. @${s.subagent.name}${s.subagent.description ? ` — ${s.subagent.description}` : ""}${mcps ? ` [${mcps}]` : ""}`
333
+ )
334
+ })
335
+ lines.push("")
336
+ lines.push(
337
+ `Be more specific, or name the subagent directly: @${top[0].subagent.name}`
338
+ )
339
+ showToast(client, `${scored.length} subagents match`, "info")
340
+ return lines.join("\n")
341
+ },
342
+ }),
343
+
344
+ /**
345
+ * Stats tool: displays routing status and token savings.
346
+ *
347
+ * Shows:
348
+ * - Subagent routing map (which subagent handles which MCP servers)
349
+ * - Unassigned servers (no subagent covers them)
350
+ * - Token savings confirmation (0 MCP tokens in main session)
351
+ * - Coverage percentage
352
+ */
353
+ mcp_stats: tool({
354
+ description:
355
+ "Show MCP subagent routing status and token savings. " +
356
+ "Displays which MCP servers are routed to which subagents. " +
357
+ "MCP tools in main session are disabled — only subagents carry them.",
358
+ args: {},
359
+ async execute() {
360
+ const lines: string[] = []
361
+ const { subagents: sa, mcpNames, assignedMcps } = state
362
+
363
+ lines.push("MCP Subagent Routing Status")
364
+ lines.push("")
365
+
366
+ if (sa.length === 0) {
367
+ lines.push(" No subagents configured.")
368
+ lines.push("")
369
+ lines.push(
370
+ " Configure subagents with MCP tool scoping for token savings."
371
+ )
372
+ return lines.join("\n")
373
+ }
374
+
375
+ lines.push(
376
+ ` Strategy: Global disable → Subagent enable via tool scoping`
377
+ )
378
+ lines.push(
379
+ ` Subagents: ${sa.length} | MCP servers: ${mcpNames.length}`
380
+ )
381
+ lines.push("")
382
+ lines.push(" Subagent routing map:")
383
+ lines.push(" ─".repeat(30))
384
+
385
+ for (const s of sa) {
386
+ const mcps = s.mcpServers.join(", ")
387
+ lines.push(
388
+ ` @${s.name.padEnd(18)} → ${mcps || "no MCP servers"}`
389
+ )
390
+ if (s.description) {
391
+ lines.push(` ${" ".repeat(19)}${s.description}`)
392
+ }
393
+ }
394
+
395
+ // Find MCP servers not assigned to any subagent
396
+ const unassigned = mcpNames.filter(
397
+ (n) => !sa.some((s) => s.mcpServers.includes(n))
398
+ )
399
+ if (unassigned.length > 0) {
400
+ lines.push("")
401
+ lines.push(
402
+ ` Unassigned: ${unassigned.join(", ")} (no subagent)`
403
+ )
404
+ }
405
+
406
+ const assigned = assignedMcps.size
407
+ const pct = mcpNames.length > 0
408
+ ? Math.round((assigned / mcpNames.length) * 100)
409
+ : 0
410
+
411
+ lines.push("")
412
+ lines.push(" ─".repeat(30))
413
+ lines.push(
414
+ ` MCP tools in main session: 0 tokens (globally disabled)`
415
+ )
416
+ lines.push(
417
+ ` MCP coverage: ${assigned}/${mcpNames.length} servers routed (${pct}%)`
418
+ )
419
+
420
+ return lines.join("\n")
421
+ },
422
+ }),
423
+ },
424
+ }
425
+ }
package/src/lock.ts ADDED
@@ -0,0 +1,42 @@
1
+ import { readFile, writeFile, mkdir, stat, rename } from "node:fs/promises"
2
+ import { join, dirname } from "node:path"
3
+
4
+ const LOCK_FILENAME = "mcp-triage.json"
5
+
6
+ /** Max lock file size: 64KB — prevents memory exhaustion */
7
+ const MAX_LOCK_SIZE = 64 * 1024
8
+
9
+ export interface LockFile {
10
+ version: 1
11
+ autoCreated: Record<string, string>
12
+ }
13
+
14
+ export async function readLock(directory: string): Promise<LockFile | null> {
15
+ const path = join(directory, ".opencode", LOCK_FILENAME)
16
+ try {
17
+ const stats = await stat(path)
18
+ if (stats.size > MAX_LOCK_SIZE) return null
19
+ const raw = await readFile(path, "utf-8")
20
+ return JSON.parse(raw) as LockFile
21
+ } catch {
22
+ return null
23
+ }
24
+ }
25
+
26
+ export async function writeLock(
27
+ directory: string,
28
+ lock: LockFile
29
+ ): Promise<void> {
30
+ const path = join(directory, ".opencode", LOCK_FILENAME)
31
+ await mkdir(dirname(path), { recursive: true })
32
+
33
+ // Atomic write: write to temp file then rename
34
+ const tmpPath = path + ".tmp"
35
+ await writeFile(tmpPath, JSON.stringify(lock, null, 2) + "\n", "utf-8")
36
+ try {
37
+ await rename(tmpPath, path)
38
+ } catch {
39
+ // Rename failed (cross-device) — fallback to direct write
40
+ await writeFile(path, JSON.stringify(lock, null, 2) + "\n", "utf-8")
41
+ }
42
+ }
package/src/triage.ts ADDED
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Keyword-based subagent scoring engine.
3
+ *
4
+ * Scoring strategy (no LLM, pure text matching):
5
+ * 1. Split query into words (min 3 chars, strip punctuation)
6
+ * 2. For each subagent, score against name, description, and MCP server names
7
+ * 3. Word boundary match > substring match (15 vs 10 base points)
8
+ * 4. Name and MCP matches weighted 3x, description weighted 1x
9
+ *
10
+ * Threshold (30): minimum score gap between 1st and 2nd place for auto-routing.
11
+ * If gap < threshold, we show multiple options instead of picking one.
12
+ *
13
+ * Why these weights:
14
+ * - NAME_WEIGHT=3: subagent name is the strongest signal ("github" in query → github agent)
15
+ * - DESC_WEIGHT=1: description is broader, more false positives
16
+ * - MCP names use NAME_WEIGHT: server name matches are as strong as agent name matches
17
+ */
18
+
19
+ import type { Subagent, ScoredSubagent } from "./types.js"
20
+
21
+ /** Minimum score gap between top two candidates for confident routing */
22
+ export const THRESHOLD = 30
23
+ /** Words shorter than this are ignored (too generic) */
24
+ const MIN_WORD_LENGTH = 3
25
+ /** Multiplier for name and MCP server matches */
26
+ const NAME_WEIGHT = 3
27
+ /** Multiplier for description matches (lower — more noise) */
28
+ const DESC_WEIGHT = 1
29
+
30
+ /**
31
+ * Escapes regex special characters in a string.
32
+ * Used when building dynamic regex patterns from user input or config values.
33
+ */
34
+ function escapeRegex(s: string): string {
35
+ return s.replace(/[.*+?^${}()|[\]\\-]/g, "\\$&")
36
+ }
37
+
38
+ /**
39
+ * Scores a single word against a target string.
40
+ *
41
+ * Returns:
42
+ * - 15 if word matches as a whole word (boundary match)
43
+ * - 10 if word is a substring of target
44
+ * - 0 if no match
45
+ *
46
+ * Case-insensitive. Boundary match uses \b word boundaries.
47
+ */
48
+ function getWordBonus(word: string, target: string): number {
49
+ const re = new RegExp(`\\b${escapeRegex(word)}\\b`, "i")
50
+ if (re.test(target)) return 15
51
+ if (target.includes(word)) return 10
52
+ return 0
53
+ }
54
+
55
+ /**
56
+ * Scores all subagents against a triage query.
57
+ *
58
+ * Scoring happens in three passes:
59
+ * 1. Query words vs subagent name (highest weight)
60
+ * 2. Query words vs subagent description (lower weight)
61
+ * 3. Query words vs MCP server names assigned to the subagent (highest weight)
62
+ *
63
+ * Uses a Set for matchedBy to avoid duplicate entries when multiple
64
+ * query words match the same MCP server.
65
+ *
66
+ * Returns all subagents with their scores — caller filters by score > 0.
67
+ */
68
+ export function scoreSubagents(
69
+ query: string,
70
+ subagents: Subagent[]
71
+ ): ScoredSubagent[] {
72
+ // Normalize query: lowercase, split on whitespace, strip punctuation, filter short words
73
+ const words = query
74
+ .toLowerCase()
75
+ .split(/\s+/)
76
+ .map((w) => w.replace(/[^\p{L}\p{N}]/gu, ""))
77
+ .filter((w) => w.length >= MIN_WORD_LENGTH)
78
+
79
+ if (words.length === 0) return []
80
+
81
+ return subagents.map((subagent) => {
82
+ const nameLower = subagent.name.toLowerCase()
83
+ const descLower = subagent.description.toLowerCase()
84
+ const mcpNames = subagent.mcpServers.map((s) => s.toLowerCase())
85
+ let score = 0
86
+ // Use Set to deduplicate — multiple query words can match the same MCP server
87
+ const matched = new Set<string>()
88
+
89
+ // Pass 1: score against subagent name (strongest signal)
90
+ for (const word of words) {
91
+ const bonus = getWordBonus(word, nameLower)
92
+ if (bonus > 0) {
93
+ score += NAME_WEIGHT * bonus
94
+ matched.add(`name:${word}`)
95
+ }
96
+ }
97
+
98
+ // Pass 2: score against description (broader, more noise)
99
+ for (const word of words) {
100
+ const bonus = getWordBonus(word, descLower)
101
+ if (bonus > 0) {
102
+ score += DESC_WEIGHT * bonus
103
+ matched.add(`desc:${word}`)
104
+ }
105
+ }
106
+
107
+ // Pass 3: score against MCP server names assigned to this subagent
108
+ // Treats MCP name matches as strong as name matches
109
+ for (const mcpName of mcpNames) {
110
+ for (const word of words) {
111
+ const bonus = getWordBonus(word, mcpName)
112
+ if (bonus > 0) {
113
+ score += NAME_WEIGHT * bonus
114
+ matched.add(`mcp:${mcpName}`)
115
+ }
116
+ }
117
+ }
118
+
119
+ return { subagent, score, matchedBy: Array.from(matched).join(", ") }
120
+ })
121
+ }
package/src/types.ts ADDED
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Shared type definitions for opencode-mcp-triage.
3
+ *
4
+ * Key distinction:
5
+ * - McpConfigEntry: raw config from opencode.jsonc (has type, command, url, etc.)
6
+ * - McpServer: simplified name + description for display
7
+ * - Subagent: agent with MCP tool scoping, extracted from agent config
8
+ * - ScoredSubagent: subagent + relevance score from keyword matching
9
+ */
10
+
11
+ /**
12
+ * MCP server config entry from opencode.jsonc.
13
+ *
14
+ * OpenCode supports both "env" and "environment" keys for env vars.
15
+ * "enabled" defaults to true when omitted — we only filter out explicit false.
16
+ */
17
+ export interface McpConfigEntry {
18
+ type: "local" | "remote"
19
+ command?: string[]
20
+ args?: string[]
21
+ url?: string
22
+ env?: Record<string, string>
23
+ environment?: Record<string, string>
24
+ headers?: Record<string, string>
25
+ enabled?: boolean
26
+ description?: string
27
+ }
28
+
29
+ /**
30
+ * Simplified MCP server representation.
31
+ * Used for routing state and display — stripped of connection details.
32
+ */
33
+ export interface McpServer {
34
+ name: string
35
+ description: string
36
+ }
37
+
38
+ /**
39
+ * Subagent extracted from opencode.jsonc agent config.
40
+ *
41
+ * A subagent is any agent entry with:
42
+ * - mode !== "primary"
43
+ * - tools object containing at least one "servername_*": true entry
44
+ *
45
+ * The mcpServers array holds the server name prefixes (without _* suffix).
46
+ */
47
+ export interface Subagent {
48
+ name: string
49
+ description: string
50
+ mcpServers: string[]
51
+ }
52
+
53
+ /**
54
+ * Subagent enriched with a relevance score from triage matching.
55
+ *
56
+ * score: cumulative points from keyword matches across name, description, and MCP names
57
+ * matchedBy: human-readable explanation of which fields matched which keywords
58
+ */
59
+ export interface ScoredSubagent {
60
+ subagent: Subagent
61
+ score: number
62
+ matchedBy: string
63
+ }