switchroom 0.19.27 → 0.19.28

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 (32) hide show
  1. package/dist/agent-scheduler/index.js +5 -2
  2. package/dist/auth-broker/index.js +129 -8
  3. package/dist/cli/autoaccept-poll.js +225 -17
  4. package/dist/cli/notion-write-pretool.mjs +5 -2
  5. package/dist/cli/switchroom.js +796 -35
  6. package/dist/host-control/main.js +130 -9
  7. package/dist/vault/approvals/kernel-server.js +129 -8
  8. package/dist/vault/broker/server.js +129 -8
  9. package/package.json +3 -2
  10. package/profiles/_base/start.sh.hbs +70 -15
  11. package/telegram-plugin/dist/bridge/bridge.js +1 -0
  12. package/telegram-plugin/dist/gateway/gateway.js +568 -49
  13. package/telegram-plugin/dist/server.js +1 -0
  14. package/telegram-plugin/edit-flood-fuse.ts +230 -27
  15. package/telegram-plugin/gateway/callback-query-handlers.ts +6 -0
  16. package/telegram-plugin/gateway/gateway.ts +9 -2
  17. package/telegram-plugin/gateway/mcp-failure-hook.ts +74 -0
  18. package/telegram-plugin/inline-keyboard-callbacks.ts +202 -21
  19. package/telegram-plugin/mcp-credential-failure.ts +459 -0
  20. package/telegram-plugin/operator-events.ts +38 -0
  21. package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +58 -1
  22. package/telegram-plugin/tests/edit-flood-fuse-reply-reserve.test.ts +340 -0
  23. package/telegram-plugin/tests/finalize-callback-flood-policy.test.ts +298 -0
  24. package/telegram-plugin/tests/finalize-callback.test.ts +41 -8
  25. package/telegram-plugin/tests/mcp-credential-failure.test.ts +310 -0
  26. package/vendor/hindsight-memory/scripts/drain_pending.py +433 -11
  27. package/vendor/hindsight-memory/scripts/lib/pending.py +193 -28
  28. package/vendor/hindsight-memory/scripts/tests/test_drain_circuit_breaker.py +401 -0
  29. package/vendor/hindsight-memory/scripts/tests/test_drain_serialisation.py +286 -0
  30. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +817 -8
  31. package/vendor/hindsight-memory/settings.json +1 -1
  32. package/vendor/hindsight-memory/tests/test_hooks.py +11 -2
@@ -16,6 +16,8 @@ import { finalizeCallback, type FinalizeCallbackContext } from '../inline-keyboa
16
16
  interface Capture {
17
17
  acks: Array<{ text?: string; show_alert?: boolean }>
18
18
  edits: Array<{ text: string | { markdown: string }; opts: Record<string, unknown> }>
19
+ markupEdits: Array<Record<string, unknown>>
20
+ replies: string[]
19
21
  ackThrows?: Error
20
22
  editThrows?: Error
21
23
  }
@@ -32,13 +34,30 @@ function mkCtx(cap: Capture): FinalizeCallbackContext {
32
34
  if (cap.editThrows) throw cap.editThrows
33
35
  return { message_id: 1 }
34
36
  },
37
+ editMessageReplyMarkup: async (opts) => {
38
+ cap.markupEdits.push(opts ?? {})
39
+ return true
40
+ },
41
+ reply: async (text) => {
42
+ cap.replies.push(text)
43
+ return { message_id: 2 }
44
+ },
35
45
  }
36
46
  }
37
47
 
48
+ /**
49
+ * Passthrough stand-in for the gateway's `robustApiCall` (#3891). The helper
50
+ * now REQUIRES the retry/flood seam; these three-invariant cases are about the
51
+ * invariants, not the policy, so they hand in a transparent one. The policy's
52
+ * own outcomes are pinned in `finalize-callback-flood-policy.test.ts`.
53
+ */
54
+ const passthrough = <T>(fn: () => Promise<T>): Promise<T> => fn()
55
+
38
56
  describe('finalizeCallback — three-invariant contract', () => {
39
57
  it('invariant 1: acks the callback with the supplied toast text', async () => {
40
- const cap: Capture = { acks: [], edits: [] }
58
+ const cap: Capture = { acks: [], edits: [], markupEdits: [], replies: [] }
41
59
  await finalizeCallback(mkCtx(cap), {
60
+ apiCall: passthrough,
42
61
  ackText: 'Approved',
43
62
  newText: 'Original prompt\n\n✓ Approved by @op',
44
63
  })
@@ -48,8 +67,9 @@ describe('finalizeCallback — three-invariant contract', () => {
48
67
  })
49
68
 
50
69
  it('invariant 1: alert=true renders as full modal (show_alert: true)', async () => {
51
- const cap: Capture = { acks: [], edits: [] }
70
+ const cap: Capture = { acks: [], edits: [], markupEdits: [], replies: [] }
52
71
  await finalizeCallback(mkCtx(cap), {
72
+ apiCall: passthrough,
53
73
  ackText: 'Vault grant revoked',
54
74
  alert: true,
55
75
  newText: '...',
@@ -58,8 +78,9 @@ describe('finalizeCallback — three-invariant contract', () => {
58
78
  })
59
79
 
60
80
  it('invariant 2: strips reply_markup AND rich-edits the body in one atomic call', async () => {
61
- const cap: Capture = { acks: [], edits: [] }
81
+ const cap: Capture = { acks: [], edits: [], markupEdits: [], replies: [] }
62
82
  await finalizeCallback(mkCtx(cap), {
83
+ apiCall: passthrough,
63
84
  ackText: 'Approved',
64
85
  newText: '**✓ Approved**\n\nGrant minted at 22:38 UTC',
65
86
  })
@@ -75,8 +96,8 @@ describe('finalizeCallback — three-invariant contract', () => {
75
96
  })
76
97
 
77
98
  it('invariant 2: literalText edits a plain string (no rich wrapper, no parse_mode)', async () => {
78
- const cap: Capture = { acks: [], edits: [] }
79
- await finalizeCallback(mkCtx(cap), { ackText: 'ok', newText: 'plain', literalText: true })
99
+ const cap: Capture = { acks: [], edits: [], markupEdits: [], replies: [] }
100
+ await finalizeCallback(mkCtx(cap), { ackText: 'ok', newText: 'plain', literalText: true, apiCall: passthrough })
80
101
  expect(cap.edits[0]?.text).toBe('plain')
81
102
  expect(cap.edits[0]?.opts.parse_mode).toBeUndefined()
82
103
  })
@@ -91,8 +112,11 @@ describe('finalizeCallback — three-invariant contract', () => {
91
112
  order.push('edit-end')
92
113
  return { message_id: 1 }
93
114
  },
115
+ editMessageReplyMarkup: async () => { order.push('markup'); return true },
116
+ reply: async () => { order.push('reply'); return { message_id: 2 } },
94
117
  }
95
118
  await finalizeCallback(ctx, {
119
+ apiCall: passthrough,
96
120
  ackText: 'ok',
97
121
  newText: '...',
98
122
  synthInbound: () => { order.push('synth') },
@@ -108,8 +132,9 @@ describe('finalizeCallback — three-invariant contract', () => {
108
132
 
109
133
  it('invariant 3: async synthInbound is awaited', async () => {
110
134
  let synthResolved = false
111
- const cap: Capture = { acks: [], edits: [] }
135
+ const cap: Capture = { acks: [], edits: [], markupEdits: [], replies: [] }
112
136
  await finalizeCallback(mkCtx(cap), {
137
+ apiCall: passthrough,
113
138
  ackText: 'ok',
114
139
  newText: '...',
115
140
  synthInbound: async () => {
@@ -122,9 +147,10 @@ describe('finalizeCallback — three-invariant contract', () => {
122
147
 
123
148
  it('invariant 3: synthInbound errors are caught + logged, never propagated', async () => {
124
149
  const logs: string[] = []
125
- const cap: Capture = { acks: [], edits: [] }
150
+ const cap: Capture = { acks: [], edits: [], markupEdits: [], replies: [] }
126
151
  await expect(
127
152
  finalizeCallback(mkCtx(cap), {
153
+ apiCall: passthrough,
128
154
  ackText: 'ok',
129
155
  newText: '...',
130
156
  synthInbound: () => { throw new Error('inject_inbound IPC closed') },
@@ -143,9 +169,12 @@ describe('finalizeCallback — three-invariant contract', () => {
143
169
  const cap: Capture = {
144
170
  acks: [],
145
171
  edits: [],
172
+ markupEdits: [],
173
+ replies: [],
146
174
  editThrows: new Error('Bad Request: message to edit not found'),
147
175
  }
148
176
  await finalizeCallback(mkCtx(cap), {
177
+ apiCall: passthrough,
149
178
  ackText: 'Approved',
150
179
  newText: '...',
151
180
  synthInbound: () => { synthFired = true },
@@ -163,9 +192,12 @@ describe('finalizeCallback — three-invariant contract', () => {
163
192
  const cap: Capture = {
164
193
  acks: [],
165
194
  edits: [],
195
+ markupEdits: [],
196
+ replies: [],
166
197
  ackThrows: new Error('query is too old'),
167
198
  }
168
199
  await finalizeCallback(mkCtx(cap), {
200
+ apiCall: passthrough,
169
201
  ackText: 'Approved',
170
202
  newText: 'edited body',
171
203
  synthInbound: () => { synthFired = true },
@@ -181,8 +213,9 @@ describe('finalizeCallback — three-invariant contract', () => {
181
213
  })
182
214
 
183
215
  it('synthInbound is optional — surfaces with no model in the loop just ack + edit', async () => {
184
- const cap: Capture = { acks: [], edits: [] }
216
+ const cap: Capture = { acks: [], edits: [], markupEdits: [], replies: [] }
185
217
  await finalizeCallback(mkCtx(cap), {
218
+ apiCall: passthrough,
186
219
  ackText: 'Dismissed',
187
220
  newText: '✗ Dismissed',
188
221
  })
@@ -0,0 +1,310 @@
1
+ /**
2
+ * mcp-credential-failure.test.ts — outcome tests for "a paid MCP dependency's
3
+ * key is blocked → Ken gets ONE hard warning".
4
+ *
5
+ * THE GAP. Perplexity (and Eraser, Brevo, Postiz, Meta/Google Ads, Cloudflare)
6
+ * reach switchroom over the MCP TOOL surface, never through LiteLLM. So the
7
+ * operator-event path that PR A fixed for an OpenRouter 402 never sees them: an
8
+ * expired Perplexity key surfaced as an ordinary red step in the live feed and
9
+ * died there. Nobody was told the fleet had lost a paid capability.
10
+ *
11
+ * These assert the OBSERVABLE RESULT at the gateway seam, composing the SAME
12
+ * production functions in the SAME order `handleSessionEvent` →
13
+ * `noteMcpDependencyFailure` → `emitGatewayOperatorEvent` calls them:
14
+ * McpFailureWatcher.onToolUse/onToolResult → renderMcpFailureDetail →
15
+ * renderOperatorEvent → decideOperatorEventAudience →
16
+ * renderUserFacingFailureNotice.
17
+ *
18
+ * The three guarantees the brief names, each asserted end-to-end:
19
+ * 1. a credential-class MCP failure produces EXACTLY ONE operator alert with
20
+ * the provider, the vault key NAME, the agents and the action;
21
+ * 2. a second agent failing the same way inside the window produces NO
22
+ * second alert;
23
+ * 3. an ordinary tool error (bad query, 404, timeout, transient 429)
24
+ * produces NONE.
25
+ */
26
+
27
+ import { describe, it, expect } from 'vitest'
28
+ import {
29
+ McpFailureWatcher,
30
+ McpFailureLedger,
31
+ classifyMcpFailure,
32
+ parseMcpServerFromToolName,
33
+ renderMcpFailureDetail,
34
+ RENOTIFY_MS,
35
+ type McpFailureAlert,
36
+ } from '../mcp-credential-failure.js'
37
+ import {
38
+ renderOperatorEvent,
39
+ decideOperatorEventAudience,
40
+ isOperatorActionableKind,
41
+ renderUserFacingFailureNotice,
42
+ type OperatorEvent,
43
+ } from '../operator-events.js'
44
+
45
+ // ── Verbatim vendor failure bodies ──────────────────────────────────────────
46
+
47
+ /** Perplexity, key revoked / rejected. */
48
+ const PPLX_401 =
49
+ '{"error":{"message":"Invalid API key provided.","type":"authentication_error","code":401}}'
50
+
51
+ /** Perplexity, balance spent — 401 status, but the remedy is "top up". */
52
+ const PPLX_NO_CREDIT =
53
+ 'api.perplexity.ai responded with status 401: {"detail":"insufficient credits — please add funds to your account"}'
54
+
55
+ /**
56
+ * Perplexity, hard monthly wall. Note the status is 429 — the SAME status as a
57
+ * transient throttle — so only the wording can tell the two apart, which is
58
+ * exactly why 429 is not in the quota rule's status list.
59
+ */
60
+ const PPLX_QUOTA =
61
+ '{"error":{"message":"Monthly quota exceeded for your plan. Upgrade your plan to continue.","code":429}}'
62
+
63
+ // ── Ordinary failures that must stay SILENT ─────────────────────────────────
64
+
65
+ const ORDINARY_FAILURES: ReadonlyArray<[string, string]> = [
66
+ ['a bad query', 'Error: search query must not be empty'],
67
+ ['a 404', 'Request failed with status code 404: not found'],
68
+ ['a timeout', 'Error: ETIMEDOUT — request to api.perplexity.ai timed out after 30000ms'],
69
+ ['a transient throttle', '{"error":{"message":"Rate limit exceeded, retry after 2s","code":429}}'],
70
+ ['a transport reset', 'Error: socket hang up (ECONNRESET)'],
71
+ ['an upstream 5xx', 'Request failed with status code 503: service unavailable'],
72
+ ['no results', 'The search returned no results for that query.'],
73
+ ]
74
+
75
+ const ALLOW = ['7000000001' /* operator */, '7000000002' /* end user */]
76
+ const T0 = Date.UTC(2026, 6, 28, 10, 0, 0)
77
+
78
+ /**
79
+ * One agent's gateway, driven exactly as `handleSessionEvent` drives it. The
80
+ * `alerts` array is what `emitGatewayOperatorEvent` would have been called with.
81
+ */
82
+ function makeAgent(agent: string, watcher: McpFailureWatcher) {
83
+ const alerts: McpFailureAlert[] = []
84
+ let seq = 0
85
+ return {
86
+ alerts,
87
+ /** Drive one full MCP tool call that fails with `errorText`. */
88
+ fail(toolName: string, errorText: string, now: number): void {
89
+ const toolUseId = `toolu_${agent}_${seq++}`
90
+ watcher.onToolUse(toolUseId, toolName)
91
+ const alert = watcher.onToolResult({ toolUseId, isError: true, errorText, agent, now })
92
+ if (alert != null) alerts.push(alert)
93
+ },
94
+ /** A successful call — must never alert. */
95
+ succeed(toolName: string, now: number): void {
96
+ const toolUseId = `toolu_${agent}_${seq++}`
97
+ watcher.onToolUse(toolUseId, toolName)
98
+ const alert = watcher.onToolResult({ toolUseId, isError: false, agent, now })
99
+ if (alert != null) alerts.push(alert)
100
+ },
101
+ }
102
+ }
103
+
104
+ /** What the operator and a non-operator user actually receive for one alert. */
105
+ function route(alert: McpFailureAlert): {
106
+ operatorText: string | null
107
+ operatorChats: string[]
108
+ userText: string | null
109
+ userChats: string[]
110
+ buttons: string[]
111
+ } {
112
+ const ev: OperatorEvent = {
113
+ kind: 'mcp-dependency-blocked',
114
+ agent: alert.agents[0] ?? 'agent',
115
+ detail: renderMcpFailureDetail(alert),
116
+ suggestedActions: [],
117
+ firstSeenAt: new Date(T0),
118
+ }
119
+ const rendered = renderOperatorEvent(ev)
120
+ const { operatorChats, userNoticeChats } = decideOperatorEventAudience(ev.kind, ALLOW, ALLOW[0])
121
+ return {
122
+ operatorText: operatorChats.length > 0 ? rendered.text : null,
123
+ operatorChats,
124
+ userText: userNoticeChats.length > 0 ? renderUserFacingFailureNotice() : null,
125
+ userChats: userNoticeChats,
126
+ buttons: rendered.keyboard.inline_keyboard.flat().map(b => b.text),
127
+ }
128
+ }
129
+
130
+ describe('a blocked Perplexity key raises exactly one operator alert', () => {
131
+ it('produces ONE alert naming provider, vault key NAME, agent and action', () => {
132
+ const w = new McpFailureWatcher()
133
+ const klanker = makeAgent('klanker', w)
134
+
135
+ klanker.fail('mcp__perplexity__perplexity_search', PPLX_401, T0)
136
+
137
+ expect(klanker.alerts).toHaveLength(1)
138
+ const alert = klanker.alerts[0]
139
+ expect(alert.server).toBe('perplexity')
140
+ expect(alert.cls).toBe('credential')
141
+ expect(alert.agents).toEqual(['klanker'])
142
+ expect(alert.renotify).toBe(false)
143
+
144
+ const r = route(alert)
145
+
146
+ // Operator-only: the kind is actionable, so the user gets the brief notice.
147
+ expect(isOperatorActionableKind('mcp-dependency-blocked')).toBe(true)
148
+ expect(r.operatorChats).toEqual(['7000000001'])
149
+ expect(r.userChats).toEqual(['7000000002'])
150
+
151
+ // The card says what Ken can actually DO.
152
+ expect(r.operatorText).toContain('Perplexity')
153
+ expect(r.operatorText).toContain('perplexity/api-key') // vault key NAME
154
+ expect(r.operatorText).toContain('https://www.perplexity.ai/settings/api')
155
+ expect(r.operatorText).toContain('klanker')
156
+ expect(r.operatorText).toContain('Re-issue the key')
157
+ expect(r.buttons).toEqual(['❌ Dismiss'])
158
+
159
+ // NEVER a secret value, and never the raw provider error.
160
+ expect(r.operatorText).not.toContain('Invalid API key provided')
161
+ expect(r.operatorText).not.toMatch(/pplx-[A-Za-z0-9]/)
162
+
163
+ // The end user still sees only the diagnosis-free notice.
164
+ expect(r.userText).toBe(renderUserFacingFailureNotice())
165
+ for (const fragment of ['perplexity', '401', 'api key', 'authentication_error', 'vault']) {
166
+ expect(r.userText!.toLowerCase()).not.toContain(fragment)
167
+ }
168
+ })
169
+
170
+ it('routes a spent BALANCE to "top up", not to "re-issue the key"', () => {
171
+ const w = new McpFailureWatcher()
172
+ const a = makeAgent('klanker', w)
173
+ a.fail('mcp__perplexity__perplexity_ask', PPLX_NO_CREDIT, T0)
174
+ expect(a.alerts).toHaveLength(1)
175
+ // 401 status, but the credit wording must win — the key is fine, the
176
+ // account is empty, and re-issuing it would waste the operator's time.
177
+ expect(a.alerts[0].cls).toBe('credit')
178
+ expect(route(a.alerts[0]).operatorText).toContain('Top up the balance')
179
+ })
180
+
181
+ it('sees a hard wall wrapped in throttle language (429 + "rate limit")', () => {
182
+ // Providers routinely dress a monthly wall up as a rate limit. The wall is
183
+ // the real news; silencing it as a throttle would lose the capability
184
+ // quietly, which is the exact failure this whole feature exists to stop.
185
+ const w = new McpFailureWatcher()
186
+ const a = makeAgent('klanker', w)
187
+ a.fail(
188
+ 'mcp__perplexity__perplexity_search',
189
+ 'Rate limit exceeded — monthly quota exceeded for your plan (429)',
190
+ T0,
191
+ )
192
+ expect(a.alerts).toHaveLength(1)
193
+ expect(a.alerts[0].cls).toBe('quota')
194
+ })
195
+
196
+ it('routes a hard usage wall to the quota remedy', () => {
197
+ const w = new McpFailureWatcher()
198
+ const a = makeAgent('klanker', w)
199
+ a.fail('mcp__perplexity__perplexity_research', PPLX_QUOTA, T0)
200
+ expect(a.alerts).toHaveLength(1)
201
+ expect(a.alerts[0].cls).toBe('quota')
202
+ expect(route(a.alerts[0]).operatorText).toContain('Raise the plan limit')
203
+ })
204
+ })
205
+
206
+ describe('deduplication — a storm of failures is one alert, not a storm of alerts', () => {
207
+ it('a second agent failing the same way in the window produces NO second alert', () => {
208
+ // One shared watcher stands in for one gateway process seeing several
209
+ // agents/sub-agents; the cross-CONTAINER limit is documented in the module.
210
+ const w = new McpFailureWatcher()
211
+ const klanker = makeAgent('klanker', w)
212
+ const scribe = makeAgent('scribe', w)
213
+
214
+ klanker.fail('mcp__perplexity__perplexity_search', PPLX_401, T0)
215
+ scribe.fail('mcp__perplexity__perplexity_ask', PPLX_401, T0 + 1_000)
216
+ klanker.fail('mcp__perplexity__perplexity_search', PPLX_401, T0 + 30_000)
217
+
218
+ expect(klanker.alerts).toHaveLength(1)
219
+ expect(scribe.alerts).toHaveLength(0)
220
+ })
221
+
222
+ it('re-notifies after the 6h house cadence, and NAMES everyone seen since', () => {
223
+ const w = new McpFailureWatcher()
224
+ const klanker = makeAgent('klanker', w)
225
+ const scribe = makeAgent('scribe', w)
226
+
227
+ klanker.fail('mcp__perplexity__perplexity_search', PPLX_401, T0)
228
+ expect(klanker.alerts).toHaveLength(1)
229
+
230
+ // Silent for the whole window, however many failures land.
231
+ scribe.fail('mcp__perplexity__perplexity_ask', PPLX_401, T0 + RENOTIFY_MS - 1)
232
+ expect(scribe.alerts).toHaveLength(0)
233
+
234
+ // At the boundary it fires again, accumulating everyone seen meanwhile.
235
+ scribe.fail('mcp__perplexity__perplexity_ask', PPLX_401, T0 + RENOTIFY_MS)
236
+ expect(scribe.alerts).toHaveLength(1)
237
+ const repeat = scribe.alerts[0]
238
+ expect(repeat.renotify).toBe(true)
239
+ expect(repeat.agents).toEqual(['scribe'])
240
+ expect(repeat.occurrences).toBe(2)
241
+ expect(route(repeat).operatorText).toContain('still failing')
242
+ })
243
+
244
+ it('matches the house re-notify cadence used by the hindsight watcher', () => {
245
+ expect(RENOTIFY_MS).toBe(6 * 60 * 60 * 1000)
246
+ })
247
+
248
+ it('tracks credit and credential walls on the same server independently', () => {
249
+ const led = new McpFailureLedger()
250
+ expect(led.note({ server: 'perplexity', agent: 'a', cls: 'credential', now: T0 })).not.toBeNull()
251
+ // A different failure CLASS is a different problem with a different remedy,
252
+ // so it must not be silenced by the first one's window.
253
+ expect(led.note({ server: 'perplexity', agent: 'a', cls: 'credit', now: T0 })).not.toBeNull()
254
+ })
255
+ })
256
+
257
+ describe('ordinary tool failures never page the operator', () => {
258
+ for (const [label, body] of ORDINARY_FAILURES) {
259
+ it(`stays silent for ${label}`, () => {
260
+ const w = new McpFailureWatcher()
261
+ const a = makeAgent('klanker', w)
262
+ a.fail('mcp__perplexity__perplexity_search', body, T0)
263
+ expect(a.alerts).toEqual([])
264
+ expect(classifyMcpFailure(body)).toBe('ordinary')
265
+ })
266
+ }
267
+
268
+ it('stays silent for a SUCCESSFUL MCP call', () => {
269
+ const w = new McpFailureWatcher()
270
+ const a = makeAgent('klanker', w)
271
+ a.succeed('mcp__perplexity__perplexity_search', T0)
272
+ expect(a.alerts).toEqual([])
273
+ })
274
+
275
+ it('stays silent for a non-MCP tool that fails with auth-shaped text', () => {
276
+ // A Bash step printing "401 unauthorized" from some unrelated curl is NOT
277
+ // a fleet capability loss and must not page anyone.
278
+ const w = new McpFailureWatcher()
279
+ const a = makeAgent('klanker', w)
280
+ a.fail('Bash', 'curl: server returned status 401 unauthorized', T0)
281
+ expect(a.alerts).toEqual([])
282
+ })
283
+ })
284
+
285
+ describe('the rule is data, and it generalises past Perplexity', () => {
286
+ it('derives the server from any mcp__<server>__<tool> name', () => {
287
+ expect(parseMcpServerFromToolName('mcp__perplexity__perplexity_search')).toBe('perplexity')
288
+ expect(parseMcpServerFromToolName('mcp__meta-ads__list_campaigns')).toBe('meta-ads')
289
+ expect(parseMcpServerFromToolName('Read')).toBeNull()
290
+ expect(parseMcpServerFromToolName(undefined)).toBeNull()
291
+ })
292
+
293
+ it('covers other paid dependencies with no new code — Eraser, Brevo', () => {
294
+ const w = new McpFailureWatcher()
295
+ const a = makeAgent('klanker', w)
296
+ a.fail('mcp__eraser__create_diagram', '403 Forbidden: api key has been revoked', T0)
297
+ a.fail('mcp__brevo__send_email', '{"code":"unauthorized","message":"Key not found"}', T0)
298
+ expect(a.alerts.map(x => x.server)).toEqual(['eraser', 'brevo'])
299
+ expect(route(a.alerts[0]).operatorText).toContain('eraser/api-key')
300
+ expect(route(a.alerts[1]).operatorText).toContain('brevo/api-key')
301
+ })
302
+
303
+ it('an unregistered MCP server still alerts, with a conventional key name', () => {
304
+ const w = new McpFailureWatcher()
305
+ const a = makeAgent('klanker', w)
306
+ a.fail('mcp__somenewthing__do_it', '401 unauthorized: invalid api key', T0)
307
+ expect(a.alerts).toHaveLength(1)
308
+ expect(a.alerts[0].vaultKey).toBe('somenewthing/api-key')
309
+ })
310
+ })