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
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
|
+
}
|
package/postinstall.cjs
ADDED
|
@@ -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
|
+
}
|