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/writer.ts ADDED
@@ -0,0 +1,468 @@
1
+ /**
2
+ * Config file writer for disabling MCP tools in the main agent.
3
+ *
4
+ * Purpose: On plugin init, writes "servername_*": false entries into the
5
+ * project-level opencode.jsonc tools block. This disables MCP tools in the
6
+ * main session (saves tokens) while subagents re-enable them via tool scoping.
7
+ *
8
+ * Why string manipulation instead of JSON parse → modify → stringify?
9
+ * - opencode.jsonc uses JSONC (comments, trailing commas)
10
+ * - JSON.parse strips comments — we'd lose user comments on re-write
11
+ * - String manipulation preserves comments and formatting
12
+ *
13
+ * Trade-off: more fragile than proper JSON manipulation. We compensate with:
14
+ * - Stripping comments before regex matching
15
+ * - Position mapping between stripped and original strings
16
+ * - Validation before writing (parse check catches broken output)
17
+ *
18
+ * IMPORTANT: This only writes to the project-level config, never global.
19
+ * Project config takes priority in OpenCode's config resolution.
20
+ */
21
+
22
+ import { readFile, writeFile, mkdir } from "node:fs/promises"
23
+ import { join, dirname, sep } from "node:path"
24
+ import { homedir } from "node:os"
25
+ import { readLock, writeLock } from "./lock.js"
26
+ import type { McpServer, Subagent } from "./types.js"
27
+
28
+ /** Max config file size: 1MB — prevents memory exhaustion */
29
+ const MAX_CONFIG_SIZE = 1024 * 1024
30
+
31
+ /**
32
+ * Strips UTF-8 BOM (Byte Order Mark) from string.
33
+ * BOM is the 3-byte sequence: EF BB BF (U+FEFF)
34
+ */
35
+ function stripBOM(s: string): string {
36
+ if (s.length > 0 && s.charCodeAt(0) === 0xfeff) {
37
+ return s.slice(1)
38
+ }
39
+ return s
40
+ }
41
+
42
+ /**
43
+ * Validates a file path against path traversal attacks.
44
+ * Rejects paths containing null bytes or .. path segments.
45
+ */
46
+ function validatePath(path: string): boolean {
47
+ if (path.includes("\0")) return false
48
+ const segments = path.split(sep)
49
+ if (segments.includes("..")) return false
50
+ return true
51
+ }
52
+
53
+ /**
54
+ * Writes a file, creating parent directories as needed.
55
+ */
56
+ async function safeWriteFile(path: string, content: string): Promise<void> {
57
+ await mkdir(dirname(path), { recursive: true })
58
+ await writeFile(path, content, "utf-8")
59
+ }
60
+
61
+ /**
62
+ * Ensures all MCP server tools are disabled in the main agent's tools config.
63
+ *
64
+ * Idempotent: checks if entries already exist before writing.
65
+ * Only writes when missing entries are found.
66
+ *
67
+ * The disable pattern is "servername_*": false — OpenCode uses this glob
68
+ * pattern to match all tools from a given MCP server.
69
+ *
70
+ * @returns true if file was modified, false if already disabled or error
71
+ */
72
+ export async function ensureToolsDisabled(
73
+ directory: string,
74
+ mcpServers: string[]
75
+ ): Promise<boolean> {
76
+ if (mcpServers.length === 0) return false
77
+
78
+ const resolved = await findProjectConfigPath(directory)
79
+ if (!resolved) return false
80
+
81
+ const { path: configPath, exists } = resolved
82
+
83
+ let raw: string
84
+ if (exists) {
85
+ try {
86
+ raw = await readFile(configPath, "utf-8")
87
+ // Size limit: reject files > 1MB
88
+ if (raw.length > MAX_CONFIG_SIZE) return false
89
+ } catch {
90
+ return false
91
+ }
92
+ } else {
93
+ // No project config exists — start with empty object
94
+ raw = "{}"
95
+ }
96
+
97
+ // Strip BOM (Windows editors may prepend it)
98
+ raw = stripBOM(raw)
99
+
100
+ // Strip comments before checking — comments like // "github_*": false
101
+ // should not count as actual disable entries
102
+ const stripped = stripJsonComments(raw)
103
+ const missing = mcpServers.filter((name) => {
104
+ const regex = new RegExp(
105
+ `"${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}_\\*"\\s*:\\s*false`
106
+ )
107
+ return !regex.test(stripped)
108
+ })
109
+
110
+ // All servers already disabled — nothing to do
111
+ if (missing.length === 0) return false
112
+
113
+ // Build the new entries as JSON text (one per line for readability)
114
+ const newEntries = missing.map((name) => `"${name}_*": false`).join(",\n ")
115
+
116
+ // Use stripped version to find "tools" — avoids matching comments
117
+ const toolsMatch = stripped.match(/"tools"\s*:\s*\{/)
118
+ let modified: string
119
+
120
+ if (toolsMatch) {
121
+ // "tools" block exists — insert entries after opening brace
122
+ // Map position from stripped string back to original (comments shift positions)
123
+ const insertPos = mapStrippedPosition(raw, stripped, toolsMatch.index! + toolsMatch[0].length)
124
+ const prefix = raw.slice(0, insertPos)
125
+ const suffix = raw.slice(insertPos)
126
+
127
+ // Check if tools block is empty {} (skip comments to find real closing brace)
128
+ const suffixStripped = stripJsonComments(suffix)
129
+ const emptyBlockMatch = suffixStripped.match(/^\s*\}/)
130
+ if (emptyBlockMatch) {
131
+ // Empty block: replace {} with { newEntries }
132
+ const emptyEndPos = mapStrippedPosition(raw, suffix, suffixStripped.indexOf("}") + 1)
133
+ modified = prefix + "\n " + newEntries + "\n " + raw.slice(insertPos + emptyEndPos)
134
+ } else {
135
+ // Non-empty block: prepend entries after opening brace
136
+ // We prepend (not append) so user's existing entries stay at the bottom
137
+ modified = prefix + "\n " + newEntries + ",\n " + suffix.trimStart()
138
+ }
139
+ } else {
140
+ // No "tools" block — create one before the closing root brace
141
+ const toolsBlock = `"tools": {\n ${newEntries}\n }`
142
+
143
+ // Use stripped version to find closing brace (comments can contain } chars)
144
+ const strippedForBrace = stripJsonComments(raw)
145
+ const closingBrace = findClosingRootBrace(strippedForBrace)
146
+
147
+ if (closingBrace >= 0) {
148
+ // Found closing brace — insert tools block before it
149
+ // Use lastIndexOf on original since stripped positions don't map cleanly here
150
+ const lastBrace = raw.lastIndexOf("}")
151
+ const beforeRaw = raw.slice(0, lastBrace).trimEnd()
152
+ const afterRaw = raw.slice(lastBrace)
153
+ // Add comma only if root object has other keys
154
+ const prefix = beforeRaw === "{" ? "" : ","
155
+ modified = beforeRaw + prefix + "\n " + toolsBlock + "\n" + afterRaw
156
+ } else {
157
+ // No closing brace found (malformed JSON) — append tools block
158
+ modified = raw.trimEnd() + ",\n " + toolsBlock + "\n}"
159
+ }
160
+ }
161
+
162
+ // Safety check: validate the modified content parses as valid JSON
163
+ // Catches bugs in string manipulation before corrupting the config file
164
+ try {
165
+ JSON.parse(stripJsonComments(modified))
166
+ } catch {
167
+ throw new Error("Generated invalid JSONC when disabling MCP tools")
168
+ }
169
+
170
+ await safeWriteFile(configPath, modified)
171
+ return true
172
+ }
173
+
174
+ /**
175
+ * Ensures auto-created subagents exist for all unassigned MCP servers.
176
+ *
177
+ * An MCP server is "unassigned" when no existing subagent covers it AND
178
+ * it hasn't been previously auto-created and removed by the user (tracked
179
+ * via the lock file).
180
+ *
181
+ * Creates one subagent per MCP server with:
182
+ * - name = MCP server name
183
+ * - description = server description, or "<name> operations" fallback
184
+ * - mode = "subagent"
185
+ * - tools = { "name_*": true }
186
+ *
187
+ * @returns number of subagents created
188
+ */
189
+ export async function ensureSubagentsCreated(
190
+ directory: string,
191
+ mcpServers: McpServer[],
192
+ existingSubagents: Subagent[]
193
+ ): Promise<number> {
194
+ if (mcpServers.length === 0) return 0
195
+
196
+ // Find which MCPs are already covered by existing subagents
197
+ const covered = new Set<string>()
198
+ for (const sa of existingSubagents) {
199
+ for (const m of sa.mcpServers) {
200
+ covered.add(m)
201
+ }
202
+ }
203
+
204
+ // Read lock file — MCPs in autoCreated were previously created
205
+ // If user deleted them, they stay in the lock file and we respect that
206
+ const lock = await readLock(directory)
207
+ const previouslyCreated = new Set(
208
+ lock ? Object.keys(lock.autoCreated) : []
209
+ )
210
+
211
+ // MCPs that need subagents: not covered AND not previously declined
212
+ const toCreate = mcpServers.filter(
213
+ (m) => !covered.has(m.name) && !previouslyCreated.has(m.name)
214
+ )
215
+
216
+ if (toCreate.length === 0) return 0
217
+
218
+ const resolved = await findProjectConfigPath(directory)
219
+ if (!resolved) return 0
220
+
221
+ let raw: string
222
+ try {
223
+ raw = await readFile(resolved.path, "utf-8")
224
+ // Size limit: reject files > 1MB
225
+ if (raw.length > MAX_CONFIG_SIZE) return 0
226
+ } catch {
227
+ raw = "{}"
228
+ }
229
+
230
+ // Strip BOM (Windows editors may prepend it)
231
+ raw = stripBOM(raw)
232
+
233
+ // Build subagent entries as JSON text
234
+ const entries = toCreate.map((mcp) => {
235
+ const desc = mcp.description
236
+ ? jsonEscape(mcp.description)
237
+ : `${mcp.name} operations`
238
+ return `"${mcp.name}": {\n "description": "${desc}",\n "mode": "subagent",\n "tools": {\n "${mcp.name}_*": true\n }\n }`
239
+ })
240
+
241
+ const entriesText = entries.join(",\n ")
242
+
243
+ const stripped = stripJsonComments(raw)
244
+ const agentMatch = stripped.match(/"agent"\s*:\s*\{/)
245
+
246
+ let modified: string
247
+
248
+ if (agentMatch) {
249
+ const insertPos = mapStrippedPosition(
250
+ raw, stripped,
251
+ agentMatch.index! + agentMatch[0].length
252
+ )
253
+ const prefix = raw.slice(0, insertPos)
254
+ const suffix = raw.slice(insertPos)
255
+
256
+ const suffixStripped = stripJsonComments(suffix)
257
+ const emptyBlockMatch = suffixStripped.match(/^\s*\}/)
258
+ if (emptyBlockMatch) {
259
+ const emptyEndPos = mapStrippedPosition(
260
+ raw, suffix,
261
+ suffixStripped.indexOf("}") + 1
262
+ )
263
+ modified = prefix + "\n " + entriesText + "\n " +
264
+ raw.slice(insertPos + emptyEndPos)
265
+ } else {
266
+ modified =
267
+ prefix + "\n " + entriesText + ",\n " + suffix.trimStart()
268
+ }
269
+ } else {
270
+ const agentBlock =
271
+ `"agent": {\n ${entriesText}\n }`
272
+
273
+ const strippedForBrace = stripJsonComments(raw)
274
+ const closingBrace = findClosingRootBrace(strippedForBrace)
275
+
276
+ if (closingBrace >= 0) {
277
+ const lastBrace = raw.lastIndexOf("}")
278
+ const beforeRaw = raw.slice(0, lastBrace).trimEnd()
279
+ const afterRaw = raw.slice(lastBrace)
280
+ const prefix = beforeRaw === "{" ? "" : ","
281
+ modified = beforeRaw + prefix + "\n " + agentBlock + "\n" + afterRaw
282
+ } else {
283
+ modified = raw.trimEnd() + ",\n " + agentBlock + "\n}"
284
+ }
285
+ }
286
+
287
+ // Safety check
288
+ try {
289
+ JSON.parse(stripJsonComments(modified))
290
+ } catch {
291
+ throw new Error("Generated invalid JSONC when creating subagent entries")
292
+ }
293
+
294
+ await safeWriteFile(resolved.path, modified)
295
+
296
+ // Update lock file
297
+ const newAutoCreated: Record<string, string> = {
298
+ ...(lock?.autoCreated ?? {}),
299
+ }
300
+ for (const m of toCreate) {
301
+ newAutoCreated[m.name] = m.name
302
+ }
303
+ await writeLock(directory, {
304
+ version: 1,
305
+ autoCreated: newAutoCreated,
306
+ })
307
+
308
+ return toCreate.length
309
+ }
310
+
311
+ /**
312
+ * Finds the project-level opencode config file path.
313
+ *
314
+ * Search order (first match wins):
315
+ * 1. .opencode/opencode.json
316
+ * 2. .opencode/opencode.jsonc
317
+ * 3. opencode.jsonc (project root)
318
+ * 4. opencode.json (project root)
319
+ *
320
+ * If no project config exists, checks for a global config.
321
+ * If global exists, returns a new project path (.opencode/opencode.jsonc)
322
+ * with exists: false — caller should create it.
323
+ *
324
+ * Returns null if neither project nor global config exists.
325
+ */
326
+ async function findProjectConfigPath(
327
+ directory: string
328
+ ): Promise<{ path: string; exists: boolean } | null> {
329
+ const paths = [
330
+ join(directory, ".opencode", "opencode.json"),
331
+ join(directory, ".opencode", "opencode.jsonc"),
332
+ join(directory, "opencode.jsonc"),
333
+ join(directory, "opencode.json"),
334
+ ]
335
+
336
+ for (const path of paths) {
337
+ if (!validatePath(path)) continue
338
+ try {
339
+ await readFile(path, "utf-8")
340
+ return { path, exists: true }
341
+ } catch {
342
+ // File not found — try next path
343
+ }
344
+ }
345
+
346
+ // No project config — check if global config exists
347
+ // If so, we'll create a project-level override with just the tools section
348
+ const globalPaths = [
349
+ join(homedir(), ".config", "opencode", "opencode.jsonc"),
350
+ join(homedir(), ".config", "opencode", "opencode.json"),
351
+ ]
352
+
353
+ for (const path of globalPaths) {
354
+ if (!validatePath(path)) continue
355
+ try {
356
+ await readFile(path, "utf-8")
357
+ // Global has config — create project-level tools-only override
358
+ const newPath = join(directory, ".opencode", "opencode.jsonc")
359
+ return { path: newPath, exists: false }
360
+ } catch {
361
+ // Global not found either — try next path
362
+ }
363
+ }
364
+
365
+ return null
366
+ }
367
+
368
+ /**
369
+ * Maps a character position from a stripped (comment-free) string back to
370
+ * the original string with comments.
371
+ *
372
+ * Used when we find a match position in the stripped version (for regex
373
+ * matching) but need to insert/modify text in the original.
374
+ *
375
+ * Works by walking both strings in parallel — when characters match,
376
+ * advance the stripped index. Always advance the original index.
377
+ * When stripped index reaches the target position, original index is
378
+ * the corresponding position in the original string.
379
+ *
380
+ * O(n) where n is the original string length.
381
+ */
382
+ function mapStrippedPosition(original: string, stripped: string, strippedPos: number): number {
383
+ let origIdx = 0
384
+ let strippedIdx = 0
385
+ while (strippedIdx < strippedPos && origIdx < original.length) {
386
+ if (original[origIdx] === stripped[strippedIdx]) {
387
+ strippedIdx++
388
+ }
389
+ origIdx++
390
+ }
391
+ return origIdx
392
+ }
393
+
394
+ /**
395
+ * Strips JSONC comments from a raw JSON string.
396
+ *
397
+ * Handles:
398
+ * - Block comments: /* ... * /
399
+ * - Line comments: // ... (negative lookbehind (?<!:) avoids matching :// in URLs)
400
+ *
401
+ * Does NOT handle trailing commas — that's handled separately in stripJsonc().
402
+ * This version is simpler because we only need it for regex matching, not parsing.
403
+ */
404
+ function stripJsonComments(raw: string): string {
405
+ let result = raw.replace(/\/\*[\s\S]*?\*\//g, "")
406
+ result = result.replace(/(?<!:)\/\/.*$/gm, "")
407
+ return result
408
+ }
409
+
410
+ function jsonEscape(s: string): string {
411
+ return s
412
+ .replace(/\\/g, "\\\\")
413
+ .replace(/"/g, '\\"')
414
+ .replace(/\n/g, "\\n")
415
+ .replace(/\r/g, "\\r")
416
+ .replace(/\t/g, "\\t")
417
+ }
418
+
419
+ /**
420
+ * Finds the position of the closing brace of the root JSON object.
421
+ *
422
+ * Scans backwards from the end of the string, tracking:
423
+ * - Brace depth: } increments, { decrements
424
+ * - String state: ignores braces inside quoted strings
425
+ * - Escape sequences: counts consecutive backslashes before quotes
426
+ * to correctly handle \\" (escaped backslash + quote that opens string)
427
+ *
428
+ * Returns the index of the root closing brace, or -1 if not found.
429
+ *
430
+ * IMPORTANT: Input should be comment-stripped. Comments can contain
431
+ * braces that would throw off the depth counter.
432
+ */
433
+ function findClosingRootBrace(raw: string): number {
434
+ let depth = 0
435
+ let inString = false
436
+
437
+ for (let i = raw.length - 1; i >= 0; i--) {
438
+ const ch = raw[i]
439
+
440
+ if (inString) {
441
+ if (ch === '"') {
442
+ // Count consecutive backslashes before the quote
443
+ // Odd count = quote is escaped (\"), even count = real string end
444
+ let backslashCount = 0
445
+ let j = i - 1
446
+ while (j >= 0 && raw[j] === '\\') {
447
+ backslashCount++
448
+ j--
449
+ }
450
+ if (backslashCount % 2 === 0) inString = false
451
+ }
452
+ continue
453
+ }
454
+
455
+ if (ch === '"') {
456
+ inString = true
457
+ continue
458
+ }
459
+
460
+ if (ch === "}") depth++
461
+ if (ch === "{") depth--
462
+
463
+ // depth === 1 means we just closed the root object
464
+ if (depth === 1 && ch === "}") return i
465
+ }
466
+
467
+ return -1
468
+ }