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/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "opencode-mcp-triage",
3
+ "version": "0.7.0",
4
+ "description": "On-demand MCP tool activation for OpenCode — saves ~80% tokens by shrinking MCP tool descriptions and routing via keyword matching",
5
+ "license": "MIT",
6
+ "author": "Carlos Spagnoletti",
7
+ "keywords": [
8
+ "opencode",
9
+ "opencode-plugin",
10
+ "mcp",
11
+ "triage",
12
+ "router",
13
+ "token-optimization",
14
+ "ai-coding-agent"
15
+ ],
16
+ "type": "module",
17
+ "main": "src/index.ts",
18
+ "bin": {
19
+ "opencode-mcp-triage": "./bin/opencode-mcp-triage.cjs"
20
+ },
21
+ "files": [
22
+ "src/",
23
+ "bin/",
24
+ ".opencode/commands/",
25
+ ".opencode/plugins/",
26
+ "postinstall.cjs",
27
+ "README.md"
28
+ ],
29
+ "scripts": {
30
+ "check": "tsc --noEmit",
31
+ "test": "vitest run",
32
+ "test:watch": "vitest",
33
+ "prepublishOnly": "npm run check",
34
+ "postinstall": "node postinstall.cjs"
35
+ },
36
+ "peerDependencies": {
37
+ "@opencode-ai/plugin": ">=1.14.0"
38
+ },
39
+ "peerDependenciesMeta": {
40
+ "@opencode-ai/plugin": {
41
+ "optional": false
42
+ }
43
+ },
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "https://github.com/cascharly/opencode-mcp-triage"
47
+ },
48
+ "devDependencies": {
49
+ "@opencode-ai/plugin": ">=1.14.0",
50
+ "@types/node": "25.7.0",
51
+ "typescript": "6.0.3",
52
+ "vitest": "^4.1.6"
53
+ },
54
+ "engines": {
55
+ "node": ">=18"
56
+ },
57
+ "publishConfig": {
58
+ "access": "public"
59
+ }
60
+ }
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * postinstall — registers the /mcp-triage slash command
4
+ * into the user's OpenCode commands directory.
5
+ *
6
+ * Set POSTINSTALL_QUIET=1 to suppress output.
7
+ */
8
+ const { existsSync, mkdirSync, copyFileSync } = require("node:fs")
9
+ const { join, dirname } = require("node:path")
10
+ const { homedir } = require("node:os")
11
+
12
+ const quiet = process.env.POSTINSTALL_QUIET === "1"
13
+
14
+ const commandDir = join(homedir(), ".config", "opencode", "commands")
15
+ const source = join(__dirname, ".opencode", "commands", "mcp-triage.md")
16
+ const target = join(commandDir, "mcp-triage.md")
17
+
18
+ if (!existsSync(commandDir)) {
19
+ mkdirSync(commandDir, { recursive: true })
20
+ }
21
+
22
+ if (existsSync(source)) {
23
+ copyFileSync(source, target)
24
+ if (!quiet) console.log("[opencode-mcp-triage] /mcp-triage command registered")
25
+ } else {
26
+ if (!quiet) console.log("[opencode-mcp-triage] Command file not found, skipping")
27
+ }
package/src/config.ts ADDED
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Config reader for opencode.jsonc files.
3
+ *
4
+ * Reads from two levels and merges (project overrides global):
5
+ * 1. Global: ~/.config/opencode/opencode.jsonc
6
+ * 2. Project: .opencode/opencode.jsonc, opencode.jsonc (project root)
7
+ *
8
+ * JSONC stripping handles:
9
+ * - Block comments: /* ... * /
10
+ * - Line comments: // ... (but not URLs with ://)
11
+ * - Trailing commas before } or ]
12
+ *
13
+ * IMPORTANT: findAndParseConfig requires json.mcp or json.agent to exist.
14
+ * Without this guard, any random opencode.json in the project could be
15
+ * misinterpreted as opencode config.
16
+ *
17
+ * Security: BOM stripping, 1MB size limit, path traversal defense.
18
+ */
19
+
20
+ import type { McpServer, McpConfigEntry, Subagent } from "./types.js"
21
+ import { readFile } from "node:fs/promises"
22
+ import { join } from "node:path"
23
+ import { homedir } from "node:os"
24
+
25
+ /** Max config file size: 1MB — prevents memory exhaustion */
26
+ const MAX_CONFIG_SIZE = 1024 * 1024
27
+
28
+ /**
29
+ * Strips UTF-8 BOM (Byte Order Mark) from string.
30
+ * Windows editors (Notepad, VSCode) may prepend BOM which breaks parsing.
31
+ * BOM is the 3-byte sequence: EF BB BF (U+FEFF)
32
+ */
33
+ function stripBOM(s: string): string {
34
+ if (s.length > 0 && s.charCodeAt(0) === 0xfeff) {
35
+ return s.slice(1)
36
+ }
37
+ return s
38
+ }
39
+
40
+ interface SubagentConfig {
41
+ mode?: string
42
+ description?: string
43
+ tools?: Record<string, boolean>
44
+ [key: string]: unknown
45
+ }
46
+
47
+ /**
48
+ * Reads all MCP servers from global + project config, merged.
49
+ *
50
+ * Project entries override global entries with the same name.
51
+ * Servers with explicit enabled: false are filtered out.
52
+ * Servers without "enabled" field are treated as enabled (OpenCode default).
53
+ *
54
+ * Returns simplified McpServer[] — connection details (command, url, etc.)
55
+ * are not needed by the triage logic.
56
+ */
57
+ export async function readMcpConfig(
58
+ directory: string
59
+ ): Promise<McpServer[]> {
60
+ const globalConfig = await findAndParseConfig(homedir())
61
+ const projectConfig = await findAndParseConfig(directory)
62
+
63
+ const globalMcp = (globalConfig?.mcp as Record<string, McpConfigEntry> | undefined) ?? {}
64
+ const projectMcp = (projectConfig?.mcp as Record<string, McpConfigEntry> | undefined) ?? {}
65
+
66
+ // Project overrides global for same-named servers
67
+ const merged: Record<string, McpConfigEntry> = { ...globalMcp, ...projectMcp }
68
+
69
+ return Object.entries(merged)
70
+ .filter(([, entry]) => entry.enabled !== false)
71
+ .map(([name, entry]) => ({
72
+ name,
73
+ description: entry.description ?? "",
74
+ }))
75
+ }
76
+
77
+ /**
78
+ * Reads subagent definitions from global + project agent config.
79
+ *
80
+ * A subagent must have:
81
+ * - mode !== "primary" (primary agents run in main session)
82
+ * - tools object with at least one "servername_*": true entry
83
+ *
84
+ * The tool scoping pattern "servername_*" maps to MCP server names.
85
+ * We extract the server name by stripping the _* or * suffix.
86
+ *
87
+ * Subagents without any MCP tool scoping are skipped — they're
88
+ * regular agents, not MCP routers.
89
+ */
90
+ export async function readSubagentConfig(
91
+ directory: string
92
+ ): Promise<Subagent[]> {
93
+ const globalConfig = await findAndParseConfig(homedir())
94
+ const projectConfig = await findAndParseConfig(directory)
95
+
96
+ const globalAgent = (globalConfig?.agent as Record<string, SubagentConfig> | undefined) ?? {}
97
+ const projectAgent = (projectConfig?.agent as Record<string, SubagentConfig> | undefined) ?? {}
98
+
99
+ // Project overrides global for same-named agents
100
+ const merged: Record<string, SubagentConfig> = { ...globalAgent, ...projectAgent }
101
+
102
+ const result: Subagent[] = []
103
+
104
+ for (const [name, entry] of Object.entries(merged)) {
105
+ // Skip primary agents — they run in main session, not as subagents
106
+ if (entry.mode === "primary") continue
107
+ if (!entry.tools || typeof entry.tools !== "object") continue
108
+
109
+ // Extract MCP server names from tool scoping patterns like "github_*": true
110
+ const tools = entry.tools as Record<string, boolean>
111
+ const mcpServers = Object.keys(tools)
112
+ .filter((k) => k.endsWith("_*") && tools[k] === true)
113
+ .map((k) => k.replace(/_?\*$/, ""))
114
+
115
+ // Skip agents without MCP tool scoping
116
+ if (mcpServers.length === 0) continue
117
+
118
+ result.push({
119
+ name,
120
+ description: entry.description ?? "",
121
+ mcpServers,
122
+ })
123
+ }
124
+
125
+ return result
126
+ }
127
+
128
+ /**
129
+ * Strips JSONC comments and trailing commas for JSON.parse compatibility.
130
+ *
131
+ * Handles:
132
+ * - Block comments /* ... * /
133
+ * - Line comments // ... (negative lookbehind avoids matching :// in URLs)
134
+ * - Trailing commas before } or ]
135
+ *
136
+ * Note: This is a simple stripper, not a full JSONC parser.
137
+ * It works for typical opencode.jsonc files but could fail on edge cases
138
+ * like // inside strings. For production use, consider a proper JSONC library.
139
+ */
140
+ function stripJsonc(raw: string): string {
141
+ let result = ""
142
+ let inString = false
143
+ let escape = false
144
+ let i = 0
145
+
146
+ while (i < raw.length) {
147
+ const ch = raw[i]
148
+
149
+ if (inString) {
150
+ result += ch
151
+ if (escape) {
152
+ escape = false
153
+ } else if (ch === "\\") {
154
+ escape = true
155
+ } else if (ch === '"') {
156
+ inString = false
157
+ }
158
+ i++
159
+ continue
160
+ }
161
+
162
+ // Block comment: /* ... */
163
+ if (ch === "/" && i + 1 < raw.length && raw[i + 1] === "*") {
164
+ i += 2
165
+ while (i < raw.length) {
166
+ if (raw[i] === "*" && i + 1 < raw.length && raw[i + 1] === "/") {
167
+ i += 2
168
+ break
169
+ }
170
+ i++
171
+ }
172
+ continue
173
+ }
174
+
175
+ // Line comment: // ... (only when not inside a string)
176
+ if (ch === "/" && i + 1 < raw.length && raw[i + 1] === "/") {
177
+ i += 2
178
+ while (i < raw.length && raw[i] !== "\n") {
179
+ i++
180
+ }
181
+ continue
182
+ }
183
+
184
+ if (ch === '"') {
185
+ inString = true
186
+ }
187
+
188
+ result += ch
189
+ i++
190
+ }
191
+
192
+ // Strip trailing commas before } or ]
193
+ result = result.replace(/,(?=\s*[}\]])/g, "")
194
+ return result
195
+ }
196
+
197
+ /**
198
+ * Finds and parses an opencode config file from a base directory.
199
+ *
200
+ * Search order (global vs project differs):
201
+ * - Global: ~/.config/opencode/opencode.jsonc → opencode.json
202
+ * - Project: .opencode/opencode.json → opencode.jsonc → opencode.jsonc → opencode.json
203
+ *
204
+ * Returns the first valid JSONC file that contains "mcp" or "agent" keys.
205
+ * The key guard prevents returning unrelated JSON files (e.g., some other
206
+ * tool's opencode.json).
207
+ *
208
+ * Returns null if no valid config is found.
209
+ */
210
+ async function findAndParseConfig(
211
+ baseDir: string
212
+ ): Promise<Record<string, unknown> | null> {
213
+ const isGlobal = baseDir === homedir()
214
+ const paths = isGlobal
215
+ ? [
216
+ join(baseDir, ".config", "opencode", "opencode.jsonc"),
217
+ join(baseDir, ".config", "opencode", "opencode.json"),
218
+ ]
219
+ : [
220
+ join(baseDir, ".opencode", "opencode.json"),
221
+ join(baseDir, ".opencode", "opencode.jsonc"),
222
+ join(baseDir, "opencode.jsonc"),
223
+ join(baseDir, "opencode.json"),
224
+ ]
225
+
226
+ for (const path of paths) {
227
+ try {
228
+ const raw = await readFile(path, "utf-8")
229
+
230
+ // Size limit: reject files > 1MB to prevent memory exhaustion
231
+ if (raw.length > MAX_CONFIG_SIZE) continue
232
+
233
+ // Strip BOM (Windows editors may prepend it)
234
+ const cleaned = stripBOM(raw)
235
+
236
+ const json = JSON.parse(stripJsonc(cleaned))
237
+ // Guard: must have mcp or agent keys to be valid opencode config
238
+ if (json && (json.mcp || json.agent)) return json
239
+ } catch {
240
+ // File not found or invalid JSON — try next path
241
+ }
242
+ }
243
+
244
+ return null
245
+ }