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/.opencode/commands/mcp-triage.md +4 -0
- package/.opencode/commands/triage.md +5 -0
- package/.opencode/plugins/opencode-mcp-triage.ts +1 -0
- package/README.md +242 -0
- package/bin/opencode-mcp-triage.cjs +764 -0
- package/package.json +60 -0
- package/postinstall.cjs +27 -0
- package/src/config.ts +245 -0
- package/src/index.ts +425 -0
- package/src/lock.ts +42 -0
- package/src/triage.ts +121 -0
- package/src/types.ts +63 -0
- package/src/writer.ts +468 -0
|
@@ -0,0 +1,764 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* opencode-mcp-triage CLI v0.6.1 — /mcp-triage slash command handler
|
|
4
|
+
*
|
|
5
|
+
* Reads MCP config and shows server status, subagent routing,
|
|
6
|
+
* and tool visibility. Runs out-of-process so cannot access
|
|
7
|
+
* runtime plugin state (use mcp_stats tool for that).
|
|
8
|
+
*
|
|
9
|
+
* Features:
|
|
10
|
+
* - Colored output matching opencode-triage style
|
|
11
|
+
* - Hidden vs exposed MCP tool visibility
|
|
12
|
+
* - Levenshtein typo correction for commands
|
|
13
|
+
* - JSON output mode (--json)
|
|
14
|
+
* - Benchmarking (--benchmark)
|
|
15
|
+
*/
|
|
16
|
+
const { readFileSync, readdirSync, existsSync } = require("node:fs")
|
|
17
|
+
const { join } = require("node:path")
|
|
18
|
+
const { homedir } = require("node:os")
|
|
19
|
+
|
|
20
|
+
const COMMANDS = {
|
|
21
|
+
status: "Show MCP server status, hidden/exposed tools, and subagent routing",
|
|
22
|
+
list: "List all configured MCP servers and subagents",
|
|
23
|
+
measure: "Measure token savings by connecting to each MCP server",
|
|
24
|
+
help: "Show available commands",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const YELLOW = "\x1b[33m"
|
|
28
|
+
const GREEN = "\x1b[32m"
|
|
29
|
+
const RED = "\x1b[31m"
|
|
30
|
+
const CYAN = "\x1b[36m"
|
|
31
|
+
const RESET = "\x1b[0m"
|
|
32
|
+
const BOLD = "\x1b[1m"
|
|
33
|
+
const DIM = "\x1b[2m"
|
|
34
|
+
|
|
35
|
+
const PLUGIN_NAME = "opencode-mcp-triage"
|
|
36
|
+
|
|
37
|
+
const GLOBAL_CFG_PATH = join(homedir(), ".config", "opencode", "opencode.jsonc")
|
|
38
|
+
const GLOBAL_CFG_PATH_JSON = join(homedir(), ".config", "opencode", "opencode.json")
|
|
39
|
+
|
|
40
|
+
function levenshtein(a, b) {
|
|
41
|
+
const matrix = Array.from({ length: b.length + 1 }, (_, i) => [i])
|
|
42
|
+
for (let j = 0; j <= a.length; j++) matrix[0][j] = j
|
|
43
|
+
for (let i = 1; i <= b.length; i++) {
|
|
44
|
+
for (let j = 1; j <= a.length; j++) {
|
|
45
|
+
const cost = b[i - 1] === a[j - 1] ? 0 : 1
|
|
46
|
+
matrix[i][j] = Math.min(
|
|
47
|
+
matrix[i - 1][j] + 1,
|
|
48
|
+
matrix[i][j - 1] + 1,
|
|
49
|
+
matrix[i - 1][j - 1] + cost
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return matrix[b.length][a.length]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function suggestCommand(typo, validCommands) {
|
|
57
|
+
let best = null
|
|
58
|
+
let bestDist = Infinity
|
|
59
|
+
for (const cmd of validCommands) {
|
|
60
|
+
const dist = levenshtein(typo, cmd)
|
|
61
|
+
if (dist < bestDist) {
|
|
62
|
+
bestDist = dist
|
|
63
|
+
best = cmd
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return bestDist <= 3 ? best : null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function stripJsonc(raw) {
|
|
70
|
+
let result = ""
|
|
71
|
+
let inString = false
|
|
72
|
+
let escape = false
|
|
73
|
+
let i = 0
|
|
74
|
+
|
|
75
|
+
while (i < raw.length) {
|
|
76
|
+
const ch = raw[i]
|
|
77
|
+
|
|
78
|
+
if (inString) {
|
|
79
|
+
result += ch
|
|
80
|
+
if (escape) {
|
|
81
|
+
escape = false
|
|
82
|
+
} else if (ch === "\\") {
|
|
83
|
+
escape = true
|
|
84
|
+
} else if (ch === '"') {
|
|
85
|
+
inString = false
|
|
86
|
+
}
|
|
87
|
+
i++
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Block comment: /* ... */
|
|
92
|
+
if (ch === "/" && i + 1 < raw.length && raw[i + 1] === "*") {
|
|
93
|
+
i += 2
|
|
94
|
+
while (i < raw.length) {
|
|
95
|
+
if (raw[i] === "*" && i + 1 < raw.length && raw[i + 1] === "/") {
|
|
96
|
+
i += 2
|
|
97
|
+
break
|
|
98
|
+
}
|
|
99
|
+
i++
|
|
100
|
+
}
|
|
101
|
+
continue
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Line comment: // ... (only when not inside a string)
|
|
105
|
+
if (ch === "/" && i + 1 < raw.length && raw[i + 1] === "/") {
|
|
106
|
+
i += 2
|
|
107
|
+
while (i < raw.length && raw[i] !== "\n") {
|
|
108
|
+
i++
|
|
109
|
+
}
|
|
110
|
+
continue
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (ch === '"') {
|
|
114
|
+
inString = true
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
result += ch
|
|
118
|
+
i++
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Strip trailing commas before } or ]
|
|
122
|
+
result = result.replace(/,(?=\s*[}\]])/g, "")
|
|
123
|
+
return result
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function stripBOM(s) {
|
|
127
|
+
if (s.charCodeAt(0) === 0xfeff) return s.slice(1)
|
|
128
|
+
return s
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function findConfig(baseDir) {
|
|
132
|
+
const isGlobal = baseDir === homedir()
|
|
133
|
+
const paths = isGlobal
|
|
134
|
+
? [
|
|
135
|
+
join(baseDir, ".config", "opencode", "opencode.jsonc"),
|
|
136
|
+
join(baseDir, ".config", "opencode", "opencode.json"),
|
|
137
|
+
]
|
|
138
|
+
: [
|
|
139
|
+
join(baseDir, ".opencode", "opencode.json"),
|
|
140
|
+
join(baseDir, ".opencode", "opencode.jsonc"),
|
|
141
|
+
join(baseDir, "opencode.jsonc"),
|
|
142
|
+
join(baseDir, "opencode.json"),
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
for (const p of paths) {
|
|
146
|
+
if (existsSync(p)) {
|
|
147
|
+
try {
|
|
148
|
+
const raw = stripBOM(readFileSync(p, "utf-8"))
|
|
149
|
+
if (raw.length > 1024 * 1024) continue
|
|
150
|
+
return JSON.parse(stripJsonc(raw))
|
|
151
|
+
} catch {
|
|
152
|
+
// try next
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return null
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function collectConfigState() {
|
|
160
|
+
let localActive = false
|
|
161
|
+
const cwd = process.cwd()
|
|
162
|
+
const localPaths = [
|
|
163
|
+
join(cwd, ".opencode", "opencode.json"),
|
|
164
|
+
join(cwd, ".opencode", "opencode.jsonc"),
|
|
165
|
+
join(cwd, "opencode.jsonc"),
|
|
166
|
+
join(cwd, "opencode.json"),
|
|
167
|
+
]
|
|
168
|
+
for (const p of localPaths) {
|
|
169
|
+
if (existsSync(p)) {
|
|
170
|
+
try {
|
|
171
|
+
const cfg = JSON.parse(stripJsonc(stripBOM(readFileSync(p, "utf-8"))))
|
|
172
|
+
localActive = (cfg.plugin || []).some(
|
|
173
|
+
(pl) => typeof pl === "string" && (pl.includes(PLUGIN_NAME) || pl === "file:" + process.cwd())
|
|
174
|
+
)
|
|
175
|
+
} catch {}
|
|
176
|
+
break
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
let globalActive = false
|
|
181
|
+
for (const p of [GLOBAL_CFG_PATH, GLOBAL_CFG_PATH_JSON]) {
|
|
182
|
+
if (existsSync(p)) {
|
|
183
|
+
try {
|
|
184
|
+
const cfg = JSON.parse(stripJsonc(stripBOM(readFileSync(p, "utf-8"))))
|
|
185
|
+
globalActive = (cfg.plugin || []).some(
|
|
186
|
+
(pl) => typeof pl === "string" && pl.includes(PLUGIN_NAME)
|
|
187
|
+
)
|
|
188
|
+
} catch {}
|
|
189
|
+
break
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return { localActive, globalActive }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function extractMcpServers(config) {
|
|
197
|
+
const mcp = config.mcp || {}
|
|
198
|
+
return Object.entries(mcp)
|
|
199
|
+
.filter(([, entry]) => entry.enabled !== false)
|
|
200
|
+
.map(([name, entry]) => ({
|
|
201
|
+
name,
|
|
202
|
+
type: entry.type || "unknown",
|
|
203
|
+
enabled: entry.enabled !== false,
|
|
204
|
+
description: entry.description || "",
|
|
205
|
+
url: entry.url || "",
|
|
206
|
+
command: entry.command || [],
|
|
207
|
+
}))
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function extractSubagents(config) {
|
|
211
|
+
const agent = config.agent || {}
|
|
212
|
+
const result = []
|
|
213
|
+
for (const [name, entry] of Object.entries(agent)) {
|
|
214
|
+
if (entry.mode === "primary") continue
|
|
215
|
+
if (!entry.tools || typeof entry.tools !== "object") continue
|
|
216
|
+
const mcps = Object.keys(entry.tools)
|
|
217
|
+
.filter((k) => k.endsWith("_*") && entry.tools[k] === true)
|
|
218
|
+
.map((k) => k.replace(/_?\*$/, ""))
|
|
219
|
+
if (mcps.length === 0) continue
|
|
220
|
+
result.push({ name, description: entry.description || "", mcps })
|
|
221
|
+
}
|
|
222
|
+
return result
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function extractDisabledPatterns(config) {
|
|
226
|
+
const tools = config.tools || {}
|
|
227
|
+
return Object.entries(tools)
|
|
228
|
+
.filter(([, v]) => v === false)
|
|
229
|
+
.map(([k]) => k)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function cmdStatus(config, asJson) {
|
|
233
|
+
const { localActive, globalActive } = collectConfigState()
|
|
234
|
+
const mcpServers = extractMcpServers(config)
|
|
235
|
+
const subagents = extractSubagents(config)
|
|
236
|
+
const disabledPatterns = extractDisabledPatterns(config)
|
|
237
|
+
const mcpNames = mcpServers.map((s) => s.name)
|
|
238
|
+
|
|
239
|
+
const assigned = new Set()
|
|
240
|
+
for (const sa of subagents) {
|
|
241
|
+
for (const m of sa.mcps) assigned.add(m)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const hidden = mcpNames.filter((n) =>
|
|
245
|
+
disabledPatterns.some((p) => p === `${n}_*`)
|
|
246
|
+
)
|
|
247
|
+
const exposed = mcpNames.filter((n) =>
|
|
248
|
+
!disabledPatterns.some((p) => p === `${n}_*`)
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
const outOfSync = []
|
|
252
|
+
if (localActive && exposed.length > 0) {
|
|
253
|
+
outOfSync.push(`${exposed.length} MCP tool(s) exposed in project while plugin is ACTIVE`)
|
|
254
|
+
}
|
|
255
|
+
if (globalActive && exposed.length > 0) {
|
|
256
|
+
outOfSync.push(`${exposed.length} MCP tool(s) exposed globally while plugin is ACTIVE`)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (asJson) {
|
|
260
|
+
const routingMap = subagents.map((sa) => ({
|
|
261
|
+
name: sa.name,
|
|
262
|
+
mcps: sa.mcps,
|
|
263
|
+
description: sa.description,
|
|
264
|
+
}))
|
|
265
|
+
console.log(JSON.stringify({
|
|
266
|
+
project: {
|
|
267
|
+
plugin: localActive ? "active" : "inactive",
|
|
268
|
+
mcpServers: mcpNames.length,
|
|
269
|
+
subagents: subagents.length,
|
|
270
|
+
},
|
|
271
|
+
global: {
|
|
272
|
+
plugin: globalActive ? "active" : "inactive",
|
|
273
|
+
},
|
|
274
|
+
mcpVisibility: {
|
|
275
|
+
hidden,
|
|
276
|
+
exposed,
|
|
277
|
+
},
|
|
278
|
+
routingMap,
|
|
279
|
+
unassigned: mcpNames.filter((n) => !assigned.has(n)),
|
|
280
|
+
outOfSync: outOfSync.length > 0 ? outOfSync : null,
|
|
281
|
+
}, null, 2))
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const scopeSummary = []
|
|
286
|
+
if (localActive) scopeSummary.push(GREEN + "●" + RESET + " local")
|
|
287
|
+
if (globalActive) scopeSummary.push(GREEN + "●" + RESET + " global")
|
|
288
|
+
if (!localActive && !globalActive) scopeSummary.push(DIM + "○ inactive" + RESET)
|
|
289
|
+
|
|
290
|
+
console.log()
|
|
291
|
+
console.log(BOLD + "● MCP Triage Status" + RESET + DIM + " — " + scopeSummary.join(" · ") + RESET)
|
|
292
|
+
console.log()
|
|
293
|
+
|
|
294
|
+
console.log(` ${DIM}MCP servers:${RESET} ${mcpNames.length} │ ${DIM}Subagents:${RESET} ${subagents.length} │ ${DIM}Assigned:${RESET} ${assigned.size}/${mcpNames.length}`)
|
|
295
|
+
console.log()
|
|
296
|
+
|
|
297
|
+
if (outOfSync.length > 0) {
|
|
298
|
+
console.log(` ${YELLOW}⚠ ${outOfSync.join("; ")} — run plugin init to hide them${RESET}`)
|
|
299
|
+
console.log()
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (hidden.length > 0) {
|
|
303
|
+
console.log(` ${DIM}── Hidden (disabled in main session) ─────────────────${RESET}`)
|
|
304
|
+
for (const n of hidden) {
|
|
305
|
+
console.log(` ${GREEN}[hidden]${RESET} ${n}`)
|
|
306
|
+
}
|
|
307
|
+
console.log()
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (exposed.length > 0) {
|
|
311
|
+
console.log(` ${DIM}── Exposed (visible in main session) ─────────────────${RESET}`)
|
|
312
|
+
for (const n of exposed) {
|
|
313
|
+
console.log(` ${YELLOW}[exposed]${RESET} ${n}`)
|
|
314
|
+
}
|
|
315
|
+
console.log()
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (hidden.length === 0 && exposed.length === 0) {
|
|
319
|
+
console.log(` ${DIM}(no MCP servers configured)${RESET}`)
|
|
320
|
+
console.log()
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (subagents.length > 0) {
|
|
324
|
+
console.log(` ${DIM}── Subagent routing map ──────────────────────────────${RESET}`)
|
|
325
|
+
for (const sa of subagents) {
|
|
326
|
+
const mcps = sa.mcps.join(", ")
|
|
327
|
+
console.log(` ${CYAN}@${sa.name.padEnd(18)}${RESET} → ${mcps || "no MCP"}${sa.description ? DIM + ` (${sa.description})` + RESET : ""}`)
|
|
328
|
+
}
|
|
329
|
+
console.log()
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (hidden.length > 0 && exposed.length === 0) {
|
|
333
|
+
console.log(` ${GREEN}All MCP tools hidden from main session${RESET}`)
|
|
334
|
+
} else if (exposed.length > 0) {
|
|
335
|
+
console.log(` ${YELLOW}${exposed.length} MCP tool(s) still visible in main session${RESET}`)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
console.log()
|
|
339
|
+
console.log(` ${DIM}── Token savings ───────────────────────────────────────${RESET}`)
|
|
340
|
+
console.log(` ${DIM}Run ${CYAN}opencode-mcp-triage measure${RESET}${DIM} to connect and measure${RESET}`)
|
|
341
|
+
console.log(` ${DIM}actual token savings from each MCP server.${RESET}`)
|
|
342
|
+
console.log()
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function cmdList(config, asJson) {
|
|
346
|
+
const mcp = config.mcp || {}
|
|
347
|
+
const agent = config.agent || {}
|
|
348
|
+
const tools = config.tools || {}
|
|
349
|
+
|
|
350
|
+
if (asJson) {
|
|
351
|
+
const servers = Object.entries(mcp).map(([name, entry]) => ({
|
|
352
|
+
name,
|
|
353
|
+
type: entry.type || "unknown",
|
|
354
|
+
enabled: entry.enabled !== false,
|
|
355
|
+
location: entry.type === "remote" ? entry.url || "" : (entry.command || []).join(" "),
|
|
356
|
+
}))
|
|
357
|
+
const subagents = Object.entries(agent)
|
|
358
|
+
.filter(([, e]) => e.mode !== "primary")
|
|
359
|
+
.map(([name, entry]) => {
|
|
360
|
+
const mcps = entry.tools
|
|
361
|
+
? Object.keys(entry.tools).filter((k) => k.endsWith("_*") && entry.tools[k] === true).map((k) => k.replace(/_?\*$/, ""))
|
|
362
|
+
: []
|
|
363
|
+
return { name, mcps, description: entry.description || "" }
|
|
364
|
+
})
|
|
365
|
+
const disabled = Object.entries(tools).filter(([, v]) => v === false).map(([p]) => p)
|
|
366
|
+
console.log(JSON.stringify({ servers, subagents, disabled }, null, 2))
|
|
367
|
+
return
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
console.log()
|
|
371
|
+
console.log(BOLD + "MCP Servers" + RESET)
|
|
372
|
+
console.log()
|
|
373
|
+
|
|
374
|
+
const entries = Object.entries(mcp)
|
|
375
|
+
if (entries.length === 0) {
|
|
376
|
+
console.log(DIM + " No MCP servers configured." + RESET)
|
|
377
|
+
} else {
|
|
378
|
+
for (const [name, entry] of entries) {
|
|
379
|
+
const enabled = entry.enabled !== false
|
|
380
|
+
const type = entry.type || "unknown"
|
|
381
|
+
const location = type === "remote" ? entry.url || "" : (entry.command || []).join(" ")
|
|
382
|
+
const status = enabled ? GREEN + "enabled" + RESET : RED + "disabled" + RESET
|
|
383
|
+
console.log(` ${name.padEnd(16)} [${type}] ${status} ${DIM}${location}${RESET}`)
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
console.log()
|
|
388
|
+
console.log(BOLD + "Subagents (MCP router)" + RESET)
|
|
389
|
+
console.log()
|
|
390
|
+
|
|
391
|
+
const subagents = extractSubagents(config)
|
|
392
|
+
if (subagents.length === 0) {
|
|
393
|
+
console.log(DIM + " No MCP subagents configured." + RESET)
|
|
394
|
+
} else {
|
|
395
|
+
for (const sa of subagents) {
|
|
396
|
+
const mcps = sa.mcps.join(", ")
|
|
397
|
+
console.log(` ${CYAN}@${sa.name.padEnd(18)}${RESET} → ${mcps || DIM + "no MCP" + RESET}${sa.description ? DIM + ` (${sa.description})` + RESET : ""}`)
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
console.log()
|
|
402
|
+
console.log(BOLD + "Global tool disables" + RESET)
|
|
403
|
+
const disabled = Object.entries(tools).filter(([, v]) => v === false)
|
|
404
|
+
if (disabled.length === 0) {
|
|
405
|
+
console.log(DIM + " No MCP tools disabled (all loaded in main session)" + RESET)
|
|
406
|
+
} else {
|
|
407
|
+
for (const [pattern] of disabled) {
|
|
408
|
+
console.log(` ${GREEN}${pattern}${RESET}`)
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
console.log()
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function cmdHelp() {
|
|
415
|
+
console.log()
|
|
416
|
+
console.log(BOLD + "opencode-mcp-triage v0.6.1" + RESET + " — Subagent Router for MCP Tools")
|
|
417
|
+
console.log()
|
|
418
|
+
console.log(" Reduces MCP token usage by disabling all MCP tools globally")
|
|
419
|
+
console.log(" and routing work to scoped subagents via @mentions.")
|
|
420
|
+
console.log()
|
|
421
|
+
console.log(BOLD + "COMMANDS" + RESET)
|
|
422
|
+
console.log()
|
|
423
|
+
console.log(" status Show MCP server status, hidden/exposed tools, and subagent routing")
|
|
424
|
+
console.log(" list List all configured MCP servers and subagents")
|
|
425
|
+
console.log(" measure Connect to MCP servers and measure token savings per turn")
|
|
426
|
+
console.log(" help Show this help")
|
|
427
|
+
console.log()
|
|
428
|
+
console.log(BOLD + "FLAGS" + RESET)
|
|
429
|
+
console.log()
|
|
430
|
+
console.log(" --json Output as JSON (all commands)")
|
|
431
|
+
console.log(" --verbose Show error diagnostics during measure")
|
|
432
|
+
console.log(" --timeout=N Per-server timeout in seconds (default: 60)")
|
|
433
|
+
console.log()
|
|
434
|
+
console.log(BOLD + "HOW IT WORKS" + RESET)
|
|
435
|
+
console.log()
|
|
436
|
+
console.log(" 1. Global tool disables remove MCP tools from main session")
|
|
437
|
+
console.log(" 2. Subagents keep scoped MCP tools via agent.tools")
|
|
438
|
+
console.log(" 3. triage_mcp() routes queries to matching @subagent")
|
|
439
|
+
console.log(" 4. LLM invokes subagent via Task tool or @mention")
|
|
440
|
+
console.log()
|
|
441
|
+
console.log(BOLD + "CONFIGURE" + RESET)
|
|
442
|
+
console.log()
|
|
443
|
+
console.log(' "tools": { "mymcp_*": false } # disable globally')
|
|
444
|
+
console.log(' "agent": { "myagent": { # create subagent')
|
|
445
|
+
console.log(' "mode": "subagent",')
|
|
446
|
+
console.log(' "description": "...",')
|
|
447
|
+
console.log(' "tools": { "mymcp_*": true }')
|
|
448
|
+
console.log(' } }')
|
|
449
|
+
console.log()
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// ── Measure (token savings) ────────────────────────────────
|
|
453
|
+
const { spawn } = require("node:child_process")
|
|
454
|
+
|
|
455
|
+
async function loadCachedTokens(verbose) {
|
|
456
|
+
const results = []
|
|
457
|
+
const authDir = join(homedir(), ".mcp-auth")
|
|
458
|
+
try {
|
|
459
|
+
const entries = readdirSync(authDir, { withFileTypes: true })
|
|
460
|
+
for (const entry of entries) {
|
|
461
|
+
const full = join(authDir, entry.name)
|
|
462
|
+
if (entry.isDirectory()) {
|
|
463
|
+
const sub = readdirSync(full, { withFileTypes: true })
|
|
464
|
+
for (const s of sub) {
|
|
465
|
+
const sf = join(full, s.name)
|
|
466
|
+
if (s.isDirectory()) continue
|
|
467
|
+
if (!s.name.endsWith("_tokens.json")) continue
|
|
468
|
+
try {
|
|
469
|
+
const raw = readFileSync(sf, "utf-8")
|
|
470
|
+
const tokens = JSON.parse(raw)
|
|
471
|
+
if (tokens.access_token) {
|
|
472
|
+
results.push({
|
|
473
|
+
token: tokens.access_token,
|
|
474
|
+
type: tokens.token_type || "Bearer",
|
|
475
|
+
})
|
|
476
|
+
}
|
|
477
|
+
} catch (e) {
|
|
478
|
+
if (verbose) process.stderr.write(` [mcp-auth: ${s.name}]`)
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
} catch (e) {
|
|
484
|
+
if (verbose) process.stderr.write(` [mcp-auth: ${e.message}]`)
|
|
485
|
+
}
|
|
486
|
+
return results
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function parseSse(text) {
|
|
490
|
+
let lastData = null
|
|
491
|
+
for (const line of text.split("\n")) {
|
|
492
|
+
if (line.startsWith("data: ")) {
|
|
493
|
+
try { lastData = JSON.parse(line.slice(6)) } catch {}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
return lastData
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function calcStats(tools) {
|
|
500
|
+
let total = 0
|
|
501
|
+
for (const t of tools) total += JSON.stringify(t).length
|
|
502
|
+
return { tools: tools.length, chars: total, tokensEst: Math.round(total / 4) }
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
async function measureViaCachedToken(name, url, cachedTokens, envHeaders, verbose) {
|
|
506
|
+
if (!/^https:\/\//.test(url)) {
|
|
507
|
+
if (verbose) process.stderr.write(` [${name}: not https]`)
|
|
508
|
+
return null
|
|
509
|
+
}
|
|
510
|
+
for (const ct of cachedTokens) {
|
|
511
|
+
const headers = {
|
|
512
|
+
"Content-Type": "application/json",
|
|
513
|
+
"Accept": "application/json, text/event-stream",
|
|
514
|
+
"Authorization": `${ct.type} ${ct.token}`,
|
|
515
|
+
...envHeaders,
|
|
516
|
+
}
|
|
517
|
+
try {
|
|
518
|
+
const initResp = await fetch(url, {
|
|
519
|
+
method: "POST", headers,
|
|
520
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: "1", method: "initialize",
|
|
521
|
+
params: { protocolVersion: "2024-11-05", capabilities: {},
|
|
522
|
+
clientInfo: { name: "scanner", version: "1.0.0" } } }),
|
|
523
|
+
})
|
|
524
|
+
if (!initResp.ok) { if (verbose) process.stderr.write(` [${name}: HTTP ${initResp.status}]`); continue }
|
|
525
|
+
const text = await initResp.text()
|
|
526
|
+
const initResult = (initResp.headers.get("content-type") || "").includes("text/event-stream")
|
|
527
|
+
? parseSse(text) : JSON.parse(text)
|
|
528
|
+
if (!initResult?.result) { if (verbose) process.stderr.write(` [${name}: no init result]`); continue }
|
|
529
|
+
const sessionId = initResp.headers.get("Mcp-Session-Id")
|
|
530
|
+
if (sessionId) headers["Mcp-Session-Id"] = sessionId
|
|
531
|
+
const toolsResp = await fetch(url, {
|
|
532
|
+
method: "POST", headers,
|
|
533
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: "2", method: "tools/list" }),
|
|
534
|
+
})
|
|
535
|
+
if (!toolsResp.ok) { if (verbose) process.stderr.write(` [${name}: tools/list HTTP ${toolsResp.status}]`); continue }
|
|
536
|
+
const toolsText = await toolsResp.text()
|
|
537
|
+
const toolsData = (toolsResp.headers.get("content-type") || "").includes("text/event-stream")
|
|
538
|
+
? parseSse(toolsText) : JSON.parse(toolsText)
|
|
539
|
+
if (toolsData.error) { if (verbose) process.stderr.write(` [${name}: ${toolsData.error.message}]`); continue }
|
|
540
|
+
return calcStats(toolsData.result?.tools || [])
|
|
541
|
+
} catch (e) {
|
|
542
|
+
if (verbose) process.stderr.write(` [${name}: ${e.message}]`)
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
return null
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
async function measureLocal(name, entry, verbose, timeoutMs) {
|
|
549
|
+
let [cmd, ...args] = entry.command || []
|
|
550
|
+
const SHORTHAND = { "netlify-mcp": ["npx", "-y", "@netlify/mcp"] }
|
|
551
|
+
const resolved = SHORTHAND[cmd]
|
|
552
|
+
if (resolved) { cmd = resolved[0]; args = [...resolved.slice(1), ...args] }
|
|
553
|
+
|
|
554
|
+
const env = { ...process.env }
|
|
555
|
+
if (entry.env) Object.assign(env, entry.env)
|
|
556
|
+
if (entry.environment) Object.assign(env, entry.environment)
|
|
557
|
+
|
|
558
|
+
return new Promise((resolve) => {
|
|
559
|
+
let proc
|
|
560
|
+
try {
|
|
561
|
+
proc = spawn(cmd, args, { env, stdio: ["pipe", "pipe", "pipe"], shell: true })
|
|
562
|
+
} catch (e) {
|
|
563
|
+
if (verbose) process.stderr.write(` [${name}: spawn ${e.message}]`)
|
|
564
|
+
return resolve(null)
|
|
565
|
+
}
|
|
566
|
+
let stdout = ""
|
|
567
|
+
let done = false
|
|
568
|
+
|
|
569
|
+
proc.stdout.setEncoding("utf-8")
|
|
570
|
+
proc.stdout.on("data", (chunk) => {
|
|
571
|
+
stdout += chunk
|
|
572
|
+
if (done) return
|
|
573
|
+
const lines = stdout.split("\n").filter(Boolean)
|
|
574
|
+
for (const line of lines) {
|
|
575
|
+
try {
|
|
576
|
+
const parsed = JSON.parse(line)
|
|
577
|
+
if ((parsed.id === 2 || parsed.id === "2") && (parsed.result || parsed.error)) {
|
|
578
|
+
done = true
|
|
579
|
+
proc.stdin.end()
|
|
580
|
+
proc.kill()
|
|
581
|
+
if (parsed.error && verbose) process.stderr.write(` [${name}: rpc ${parsed.error.message}]`)
|
|
582
|
+
resolve(parsed.error ? null : calcStats(parsed.result.tools || []))
|
|
583
|
+
return
|
|
584
|
+
}
|
|
585
|
+
} catch {}
|
|
586
|
+
}
|
|
587
|
+
})
|
|
588
|
+
proc.stdout.on("error", (e) => { if (!done) { done = true; proc.kill(); if (verbose) process.stderr.write(` [${name}: stdout ${e.message}]`); resolve(null) } })
|
|
589
|
+
proc.on("error", (e) => { if (!done) { done = true; if (verbose) process.stderr.write(` [${name}: proc ${e.message}]`); resolve(null) } })
|
|
590
|
+
proc.on("exit", (code) => { if (!done) { done = true; if (verbose && code !== 0) process.stderr.write(` [${name}: exited ${code}]`); resolve(null) } })
|
|
591
|
+
|
|
592
|
+
function send(msg) { try { proc.stdin.write(JSON.stringify(msg) + "\n") } catch {} }
|
|
593
|
+
|
|
594
|
+
setTimeout(() => { if (!done) { done = true; proc.kill(); if (verbose) process.stderr.write(` [${name}: timeout]`); resolve(null) } }, timeoutMs)
|
|
595
|
+
send({ jsonrpc: "2.0", id: "1", method: "initialize",
|
|
596
|
+
params: { protocolVersion: "2024-11-05", capabilities: {},
|
|
597
|
+
clientInfo: { name: "scanner", version: "1.0.0" } } })
|
|
598
|
+
setTimeout(() => {
|
|
599
|
+
send({ jsonrpc: "2.0", method: "notifications/initialized" })
|
|
600
|
+
send({ jsonrpc: "2.0", id: "2", method: "tools/list" })
|
|
601
|
+
}, 2000)
|
|
602
|
+
})
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
async function measureRemote(name, entry, verbose) {
|
|
606
|
+
const url = entry.url
|
|
607
|
+
if (!url || !/^https:\/\//.test(url)) {
|
|
608
|
+
if (verbose) process.stderr.write(` [${name}: not https]`)
|
|
609
|
+
return null
|
|
610
|
+
}
|
|
611
|
+
const headers = {
|
|
612
|
+
"Content-Type": "application/json",
|
|
613
|
+
"Accept": "application/json, text/event-stream",
|
|
614
|
+
...(entry.headers || {}),
|
|
615
|
+
}
|
|
616
|
+
try {
|
|
617
|
+
const initResp = await fetch(url, {
|
|
618
|
+
method: "POST", headers,
|
|
619
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: "1", method: "initialize",
|
|
620
|
+
params: { protocolVersion: "2024-11-05", capabilities: {},
|
|
621
|
+
clientInfo: { name: "scanner", version: "1.0.0" } } }),
|
|
622
|
+
})
|
|
623
|
+
if (!initResp.ok) { if (verbose) process.stderr.write(` [${name}: HTTP ${initResp.status}]`); return null }
|
|
624
|
+
const text = await initResp.text()
|
|
625
|
+
const initResult = (initResp.headers.get("content-type") || "").includes("text/event-stream")
|
|
626
|
+
? parseSse(text) : JSON.parse(text)
|
|
627
|
+
if (!initResult?.result) { if (verbose) process.stderr.write(` [${name}: no init result]`); return null }
|
|
628
|
+
const sessionId = initResp.headers.get("Mcp-Session-Id")
|
|
629
|
+
if (sessionId) headers["Mcp-Session-Id"] = sessionId
|
|
630
|
+
const toolsResp = await fetch(url, {
|
|
631
|
+
method: "POST", headers,
|
|
632
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: "2", method: "tools/list" }),
|
|
633
|
+
})
|
|
634
|
+
if (!toolsResp.ok) { if (verbose) process.stderr.write(` [${name}: tools HTTP ${toolsResp.status}]`); return null }
|
|
635
|
+
const toolsText = await toolsResp.text()
|
|
636
|
+
const toolsData = (toolsResp.headers.get("content-type") || "").includes("text/event-stream")
|
|
637
|
+
? parseSse(toolsText) : JSON.parse(toolsText)
|
|
638
|
+
if (toolsData.error) { if (verbose) process.stderr.write(` [${name}: ${toolsData.error.message}]`); return null }
|
|
639
|
+
return calcStats(toolsData.result?.tools || [])
|
|
640
|
+
} catch (e) {
|
|
641
|
+
if (verbose) process.stderr.write(` [${name}: ${e.message}]`)
|
|
642
|
+
return null
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
async function cmdMeasure(config, asJson, verbose, perServerTimeout) {
|
|
647
|
+
const mcps = config.mcp || {}
|
|
648
|
+
const names = Object.keys(mcps)
|
|
649
|
+
const cachedTokens = await loadCachedTokens(verbose)
|
|
650
|
+
const savings = {}
|
|
651
|
+
|
|
652
|
+
if (!asJson) process.stderr.write(" Measuring")
|
|
653
|
+
for (const name of names) {
|
|
654
|
+
const entry = mcps[name]
|
|
655
|
+
if (entry.enabled === false) continue
|
|
656
|
+
|
|
657
|
+
let result = null
|
|
658
|
+
const isMcpRemote = entry.type === "local" &&
|
|
659
|
+
entry.command?.[0] === "mcp-remote" &&
|
|
660
|
+
entry.command?.[1]
|
|
661
|
+
|
|
662
|
+
try {
|
|
663
|
+
if (!asJson) process.stderr.write(".")
|
|
664
|
+
if (isMcpRemote) {
|
|
665
|
+
result = await measureViaCachedToken(name, entry.command[1], cachedTokens, entry.headers || {}, verbose)
|
|
666
|
+
} else if (entry.type === "local") {
|
|
667
|
+
result = await measureLocal(name, entry, verbose, perServerTimeout)
|
|
668
|
+
} else {
|
|
669
|
+
result = await measureRemote(name, entry, verbose)
|
|
670
|
+
}
|
|
671
|
+
} catch (e) {
|
|
672
|
+
if (verbose) process.stderr.write(` [${name}: ${e.message}]`)
|
|
673
|
+
}
|
|
674
|
+
if (result) savings[name] = result
|
|
675
|
+
}
|
|
676
|
+
if (!asJson) process.stderr.write(" done\n")
|
|
677
|
+
|
|
678
|
+
if (asJson) {
|
|
679
|
+
console.log(JSON.stringify({ savings }, null, 2))
|
|
680
|
+
return
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
const allNames = Object.keys(savings)
|
|
684
|
+
if (allNames.length === 0) {
|
|
685
|
+
console.log(`\n ${DIM}(no MCP servers connected or all failed)${RESET}\n`)
|
|
686
|
+
return
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
let grandChars = 0, grandTokenEst = 0, grandTools = 0
|
|
690
|
+
console.log(`\n${BOLD} TOKENS SAVED PER TURN${RESET} ${DIM}(by routing MCPs to subagents)${RESET}\n`)
|
|
691
|
+
for (const name of allNames) {
|
|
692
|
+
const s = savings[name]
|
|
693
|
+
const line = ` ${name.padEnd(12)} ${GREEN}${String(s.tools).padStart(3)} tools${RESET} ${String(s.chars).padStart(7)} chars ~${CYAN}${String(s.tokensEst).padStart(5)} tokens${RESET}`
|
|
694
|
+
console.log(line)
|
|
695
|
+
grandChars += s.chars
|
|
696
|
+
grandTokenEst += s.tokensEst
|
|
697
|
+
grandTools += s.tools
|
|
698
|
+
}
|
|
699
|
+
console.log(` ${DIM}${"-".repeat(52)}${RESET}`)
|
|
700
|
+
console.log(` ${BOLD}${"TOTAL".padEnd(12)}${RESET} ${GREEN}${String(grandTools).padStart(3)} tools${RESET} ${String(grandChars).padStart(7)} chars ~${CYAN}${String(grandTokenEst).padStart(6)} tokens${RESET}`)
|
|
701
|
+
console.log(` ${DIM}${"=".repeat(52)}${RESET}`)
|
|
702
|
+
console.log(` ${BOLD}Each user turn saves ~${grandTokenEst.toLocaleString()} tokens${RESET}`)
|
|
703
|
+
console.log(` ${DIM}that would otherwise be sent with every prompt.${RESET}\n`)
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// ── Main ───────────────────────────────────────────────────
|
|
707
|
+
const args = process.argv.slice(2)
|
|
708
|
+
const rawCmd = args[0] || "help"
|
|
709
|
+
const flags = args.slice(1)
|
|
710
|
+
const asJson = flags.includes("--json")
|
|
711
|
+
const verbose = flags.includes("--verbose")
|
|
712
|
+
|
|
713
|
+
// Parse --timeout=N (seconds) from flags
|
|
714
|
+
let perServerTimeout = 60000 // default 60s
|
|
715
|
+
const timeoutFlag = flags.find(f => /^--timeout(=.+)?$/.test(f))
|
|
716
|
+
if (timeoutFlag) {
|
|
717
|
+
const val = timeoutFlag.includes("=") ? timeoutFlag.split("=")[1] : flags[flags.indexOf(timeoutFlag) + 1]
|
|
718
|
+
const n = parseInt(val, 10)
|
|
719
|
+
if (n > 0) perServerTimeout = n * 1000
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const cwd = process.cwd()
|
|
723
|
+
const globalConfig = findConfig(homedir())
|
|
724
|
+
const projectConfig = findConfig(cwd)
|
|
725
|
+
|
|
726
|
+
const config = {
|
|
727
|
+
mcp: { ...(globalConfig?.mcp || {}), ...(projectConfig?.mcp || {}) },
|
|
728
|
+
agent: { ...(globalConfig?.agent || {}), ...(projectConfig?.agent || {}) },
|
|
729
|
+
tools: { ...(globalConfig?.tools || {}), ...(projectConfig?.tools || {}) },
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
if (!globalConfig && !projectConfig) {
|
|
733
|
+
console.log("No opencode.jsonc found in project or global config.")
|
|
734
|
+
process.exit(1)
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
const validCommands = Object.keys(COMMANDS)
|
|
738
|
+
if (!validCommands.includes(rawCmd)) {
|
|
739
|
+
const suggestion = suggestCommand(rawCmd, validCommands)
|
|
740
|
+
if (suggestion) {
|
|
741
|
+
console.log(`Did you mean "${suggestion}"? (typo: "${rawCmd}")`)
|
|
742
|
+
console.log("")
|
|
743
|
+
} else {
|
|
744
|
+
console.log(`Unknown command: "${rawCmd}"`)
|
|
745
|
+
console.log("")
|
|
746
|
+
}
|
|
747
|
+
cmdHelp()
|
|
748
|
+
process.exit(1)
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
switch (rawCmd) {
|
|
752
|
+
case "list":
|
|
753
|
+
cmdList(config, asJson)
|
|
754
|
+
break
|
|
755
|
+
case "status":
|
|
756
|
+
cmdStatus(config, asJson)
|
|
757
|
+
break
|
|
758
|
+
case "measure":
|
|
759
|
+
cmdMeasure(config, asJson, verbose, perServerTimeout).then(() => process.exit(0)).catch((e) => { process.stderr.write(`\n Fatal: ${e.message}\n`); process.exit(1) })
|
|
760
|
+
break
|
|
761
|
+
case "help":
|
|
762
|
+
default:
|
|
763
|
+
cmdHelp()
|
|
764
|
+
}
|