@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.
Files changed (2) hide show
  1. package/index.js +50 -18
  2. 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 checkAndHeal(client, reason, { quiet = false } = {}) {
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
- for (const [name, s] of Object.entries(before)) {
45
- if (s?.status !== "failed") continue
46
- try {
47
- const { data } = await client.mcp.connect({ path: { name } })
48
- if (data) healed.push(name)
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) bits.push(`failed: ${sum.failed.join(", ")}`)
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
- return Object.entries(statuses)
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) => console.log(`[mcp-watchdog] startup check failed: ${e.message}`))
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
- console.log(`[mcp-watchdog] check failed: ${e.message}`),
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.join(", ") || "none"}\n` +
148
+ `Still failing: ${r.failed.length ? `\n${failing}` : "none"}\n` +
117
149
  `Needs auth: ${r.needsAuth.join(", ") || "none"}`
118
150
  )
119
151
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrcarb0n/opencode-mcp-watchdog",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Opencode plugin: auto-check and reconnect failed MCP servers, with TUI toast status",
5
5
  "type": "module",
6
6
  "main": "index.js",