@mrcarb0n/opencode-mcp-watchdog 1.0.0 → 1.0.1
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/index.js +50 -18
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -23,6 +23,21 @@ async function showToast(client, body) {
|
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
function shorten(err, max = 80) {
|
|
27
|
+
const s = String(err).replace(/\s+/g, " ").trim()
|
|
28
|
+
return s.length > max ? `${s.slice(0, max - 1)}…` : s
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function logError(client, message) {
|
|
32
|
+
try {
|
|
33
|
+
Promise.resolve(
|
|
34
|
+
client.app.log({ body: { service: "mcp-watchdog", level: "error", message } }),
|
|
35
|
+
).catch(() => {})
|
|
36
|
+
} catch {
|
|
37
|
+
// Logging must never break the plugin.
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
26
41
|
function summarize(healed, after) {
|
|
27
42
|
const names = Object.keys(after)
|
|
28
43
|
const ok = names.filter((n) => after[n]?.status === "connected").length
|
|
@@ -35,27 +50,28 @@ function summarize(healed, after) {
|
|
|
35
50
|
return { total: names.length, ok, failed, needsAuth, disabled, recovered }
|
|
36
51
|
}
|
|
37
52
|
|
|
38
|
-
async function
|
|
39
|
-
if (Date.now() - lastRun < DEDUP_WINDOW_MS) return null
|
|
40
|
-
lastRun = Date.now()
|
|
41
|
-
|
|
53
|
+
async function runCheck(client, reason, { quiet = false } = {}) {
|
|
42
54
|
const before = await getStatuses(client)
|
|
55
|
+
const failedNames = Object.entries(before)
|
|
56
|
+
.filter(([, s]) => s?.status === "failed")
|
|
57
|
+
.map(([name]) => name)
|
|
58
|
+
const settled = await Promise.allSettled(failedNames.map((name) => client.mcp.connect({ path: { name } })))
|
|
43
59
|
const healed = []
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
} catch {
|
|
50
|
-
// Still down — reported below.
|
|
51
|
-
}
|
|
52
|
-
}
|
|
60
|
+
const connectErrors = {}
|
|
61
|
+
settled.forEach((r, i) => {
|
|
62
|
+
if (r.status === "fulfilled") healed.push(failedNames[i])
|
|
63
|
+
else connectErrors[failedNames[i]] = r.reason?.message ?? String(r.reason)
|
|
64
|
+
})
|
|
53
65
|
const after = await getStatuses(client).catch(() => before)
|
|
54
66
|
const sum = summarize(healed, after)
|
|
67
|
+
sum.errors = Object.fromEntries(sum.failed.map((n) => [n, after[n]?.error ?? connectErrors[n] ?? ""]))
|
|
55
68
|
|
|
56
69
|
const bits = [`${sum.ok}/${sum.total} connected`]
|
|
57
70
|
if (sum.recovered.length) bits.push(`reconnected: ${sum.recovered.join(", ")}`)
|
|
58
|
-
if (sum.failed.length)
|
|
71
|
+
if (sum.failed.length)
|
|
72
|
+
bits.push(
|
|
73
|
+
`failed: ${sum.failed.map((n) => (sum.errors[n] ? `${n} (${shorten(sum.errors[n])})` : n)).join(", ")}`,
|
|
74
|
+
)
|
|
59
75
|
if (sum.needsAuth.length) bits.push(`needs auth: ${sum.needsAuth.join(", ")}`)
|
|
60
76
|
if (sum.disabled.length) bits.push(`disabled: ${sum.disabled.join(", ")}`)
|
|
61
77
|
const variant = sum.failed.length ? "error" : sum.needsAuth.length ? "warning" : "success"
|
|
@@ -71,8 +87,23 @@ async function checkAndHeal(client, reason, { quiet = false } = {}) {
|
|
|
71
87
|
return sum
|
|
72
88
|
}
|
|
73
89
|
|
|
90
|
+
let inFlight = null
|
|
91
|
+
|
|
92
|
+
// Dedup + join in-flight runs; the cooldown starts when a run completes.
|
|
93
|
+
async function checkAndHeal(client, reason, opts) {
|
|
94
|
+
if (inFlight) return inFlight
|
|
95
|
+
if (Date.now() - lastRun < DEDUP_WINDOW_MS) return null
|
|
96
|
+
inFlight = runCheck(client, reason, opts).finally(() => {
|
|
97
|
+
lastRun = Date.now()
|
|
98
|
+
inFlight = null
|
|
99
|
+
})
|
|
100
|
+
return inFlight
|
|
101
|
+
}
|
|
102
|
+
|
|
74
103
|
function formatStatus(statuses) {
|
|
75
|
-
|
|
104
|
+
const entries = Object.entries(statuses)
|
|
105
|
+
if (!entries.length) return "No MCP servers configured."
|
|
106
|
+
return entries
|
|
76
107
|
.map(([name, s]) => {
|
|
77
108
|
const icon =
|
|
78
109
|
s.status === "connected" ? "✓" : s.status === "disabled" ? "○" : s.status === "failed" ? "✗" : "⚠"
|
|
@@ -85,14 +116,14 @@ function formatStatus(statuses) {
|
|
|
85
116
|
export const McpWatchdog = async ({ client }) => {
|
|
86
117
|
// Opencode itself triggers this on every startup.
|
|
87
118
|
setTimeout(() => {
|
|
88
|
-
checkAndHeal(client, "startup").catch((e) =>
|
|
119
|
+
checkAndHeal(client, "startup").catch((e) => logError(client, `startup check failed: ${e.message}`))
|
|
89
120
|
}, STARTUP_DELAY_MS)
|
|
90
121
|
|
|
91
122
|
return {
|
|
92
123
|
event: async ({ event }) => {
|
|
93
124
|
if (event.type === "server.connected") {
|
|
94
125
|
await checkAndHeal(client, "server.connected").catch((e) =>
|
|
95
|
-
|
|
126
|
+
logError(client, `check failed: ${e.message}`),
|
|
96
127
|
)
|
|
97
128
|
}
|
|
98
129
|
if (event.type === "session.error") {
|
|
@@ -111,9 +142,10 @@ export const McpWatchdog = async ({ client }) => {
|
|
|
111
142
|
if (args.action === "status") return formatStatus(await getStatuses(client))
|
|
112
143
|
const r = await checkAndHeal(client, "manual")
|
|
113
144
|
if (!r) return "Check ran recently — see toast for status."
|
|
145
|
+
const failing = r.failed.map((n) => (r.errors[n] ? ` ✗ ${n} — ${r.errors[n]}` : ` ✗ ${n}`)).join("\n")
|
|
114
146
|
return (
|
|
115
147
|
`Reconnected: ${r.recovered.join(", ") || "none"}\n` +
|
|
116
|
-
`Still failing: ${r.failed.
|
|
148
|
+
`Still failing: ${r.failed.length ? `\n${failing}` : "none"}\n` +
|
|
117
149
|
`Needs auth: ${r.needsAuth.join(", ") || "none"}`
|
|
118
150
|
)
|
|
119
151
|
},
|