@cat-factory/app 0.221.2 → 0.223.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.
@@ -84,6 +84,10 @@ const META: Record<Notification['type'], { icon: string; color: Accent }> = {
84
84
  // Runs were paused by the spend safeguard. Workspace-scoped (no block to reveal); "act" just
85
85
  // marks it read (the human raises the budget then resumes from the spend panel).
86
86
  budget_paused: { icon: 'i-lucide-wallet', color: 'warning' },
87
+ // Spend crossed an alert threshold, or is projected to overrun the budget before the period
88
+ // ends. The PROACTIVE sibling of `budget_paused`, so it is amber rather than red: nothing has
89
+ // stopped yet, which is the entire point of it arriving. "act" just marks it read.
90
+ budget_threshold: { icon: 'i-lucide-trending-up', color: 'warning' },
87
91
  // Stored credentials could not be decrypted (the ENCRYPTION_KEY changed since they were
88
92
  // sealed). Not block-scoped; "act" drops the listed stale ciphertexts so they can be re-entered
89
93
  // (or restore the previous key to recover them instead).
@@ -117,6 +121,7 @@ const ACTION_KEYS: Record<Notification['type'], string> = {
117
121
  initiative: 'layout.notifications.action.initiative',
118
122
  platform_health: 'layout.notifications.action.platform_health',
119
123
  budget_paused: 'layout.notifications.action.budget_paused',
124
+ budget_threshold: 'layout.notifications.action.budget_threshold',
120
125
  key_drift: 'layout.notifications.action.key_drift',
121
126
  infra_unreachable: 'layout.notifications.action.infra_unreachable',
122
127
  }
@@ -352,6 +352,34 @@ watch(
352
352
  </section>
353
353
  </div>
354
354
 
355
+ <!-- The TCO axes: what a repository and a ticket actually cost. Spend-only, like the
356
+ pair above, because a run's activity is already sliced by the service that owns
357
+ the repo and there is no second population to pair a ticket with. -->
358
+ <div class="grid gap-6 md:grid-cols-2">
359
+ <section>
360
+ <h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
361
+ {{ t('reports.spend.byRepo') }}
362
+ </h2>
363
+ <ReportsSpendBreakdown
364
+ :rows="view.spend.byRepo"
365
+ :currency="currency"
366
+ test-id="reports-spend-repo"
367
+ :label-of="sliceLabel"
368
+ />
369
+ </section>
370
+ <section>
371
+ <h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
372
+ {{ t('reports.spend.byTicket') }}
373
+ </h2>
374
+ <ReportsSpendBreakdown
375
+ :rows="view.spend.byTicket"
376
+ :currency="currency"
377
+ test-id="reports-spend-ticket"
378
+ :label-of="sliceLabel"
379
+ />
380
+ </section>
381
+ </div>
382
+
355
383
  <!-- The shared axis: spend AND activity for the same grouping, side by side. -->
356
384
  <section class="flex flex-col gap-3">
357
385
  <div class="flex flex-wrap items-center gap-2">
@@ -16,6 +16,7 @@
16
16
  import { computed, onMounted, reactive, ref } from 'vue'
17
17
  import type { CapabilityCredentialStatus } from '~/types/capabilityCredentials'
18
18
  import SecretInput from '~/components/common/SecretInput.vue'
19
+ import ToolServerChecklist from '~/components/settings/ToolServerChecklist.vue'
19
20
 
20
21
  const { t, d } = useI18n()
21
22
  const store = useCapabilityCredentialsStore()
@@ -63,6 +64,19 @@ onMounted(async () => {
63
64
  }
64
65
  })
65
66
 
67
+ // The tool-server inventory that renders above the checklist. Its own read (and its own failure
68
+ // report, for the same reason): the two surfaces answer different questions off different endpoints,
69
+ // so one failing must not blank the other. Both resolve the same 403, so this is only reached by a
70
+ // caller the backend has already admitted.
71
+ const toolServers = useToolServersStore()
72
+ onMounted(async () => {
73
+ try {
74
+ await toolServers.load()
75
+ } catch (e) {
76
+ present(e, 'settings.toolServers.toast.loadFailed')
77
+ }
78
+ })
79
+
66
80
  async function saveKey(key: string) {
67
81
  const value = (drafts[key] ?? '').trim()
68
82
  if (!value) return
@@ -99,6 +113,11 @@ async function removeKey(key: string) {
99
113
 
100
114
  <template>
101
115
  <div class="space-y-4" data-testid="capability-credentials-panel">
116
+ <!-- The servers FIRST, because they are what the keys below authenticate: a bare list of
117
+ variable names does not tell an operator which of them matters, and the Test button is the
118
+ only thing on either surface that can say whether the value they typed works. -->
119
+ <ToolServerChecklist />
120
+
102
121
  <p class="text-sm text-slate-400">
103
122
  {{ t('settings.capabilityCredentials.intro') }}
104
123
  </p>
@@ -15,10 +15,11 @@
15
15
  // - "Package registries" — the private npm registries a checkout installs from (formerly an
16
16
  // Integrations-hub row). What a container can resolve its dependencies from is part of the
17
17
  // execution environment, not an optional external system a workspace links in.
18
- // - "Capability credentials" — the sealed per-workspace values behind the secrets a registered
19
- // tool server (MCP) or generative binary integration declares. What an agent's tools
20
- // authenticate as belongs beside where those agents run, and it is `secrets.manage`-only, so
21
- // the tab is HIDDEN rather than disabled for anyone without that permission.
18
+ // - "Capability credentials" — the deployment's tool servers (MCP) with a Test button each, above
19
+ // the sealed per-workspace values behind the secrets a registered tool server or generative
20
+ // binary integration declares. What an agent's tools authenticate as belongs beside where those
21
+ // agents run, and it is `secrets.manage`-only (the READ included, since both halves name the
22
+ // deployment's credential keys), so the tab is HIDDEN rather than disabled for anyone else.
22
23
  // Local-specific affordances render inline, gated on `auth.localMode?.enabled`. A tab whose
23
24
  // backend integration is disabled (503) simply doesn't render.
24
25
  import { computed, ref, watch } from 'vue'
@@ -43,6 +44,7 @@ const store = useProviderConnectionsStore()
43
44
  const auth = useAuthStore()
44
45
  const packageRegistries = usePackageRegistriesStore()
45
46
  const capabilityCredentials = useCapabilityCredentialsStore()
47
+ const toolServers = useToolServersStore()
46
48
  const { canManageSecrets } = useWorkspaceAccess()
47
49
 
48
50
  const open = computed({
@@ -93,7 +95,14 @@ const tabs = computed(() =>
93
95
  // the backend gates the read too), and `hasSurface` hides a tab with nothing in it — the
94
96
  // panel is a checklist projected from the deployment's registered capabilities, so a build
95
97
  // that registers none has no credential to type.
96
- capabilityCredentials: canManageSecrets.value && capabilityCredentials.hasSurface,
98
+ //
99
+ // EITHER surface earns the tab. A tool server that declares no credential has nothing on the
100
+ // checklist, and gating the tab on the checklist alone would leave the one server an operator
101
+ // most wants to test unreachable — while a credential whose capability is a generative
102
+ // integration has no tool-server row. Two questions, one tab, and neither is a subset of the
103
+ // other.
104
+ capabilityCredentials:
105
+ canManageSecrets.value && (capabilityCredentials.hasSurface || toolServers.hasSurface),
97
106
  }).map((value) => ({
98
107
  value,
99
108
  label: TAB_LABELS.value[value],
@@ -121,7 +130,10 @@ watch(
121
130
  // window must still open), reported by the panel, which can only do that once its tab exists.
122
131
  // Not probed at all without the permission — the backend would refuse it, and asking would
123
132
  // put a 403 in every member's console on every open.
124
- if (canManageSecrets.value) void capabilityCredentials.ensureLoaded().catch(() => {})
133
+ if (canManageSecrets.value) {
134
+ void capabilityCredentials.ensureLoaded().catch(() => {})
135
+ void toolServers.ensureLoaded().catch(() => {})
136
+ }
125
137
  activeTab.value = openInfrastructureTab(tabValues.value, ui.infrastructureTab)
126
138
  },
127
139
  { immediate: true },
@@ -0,0 +1,264 @@
1
+ <script setup lang="ts">
2
+ // Tool servers (MCP) — the deployment's registered servers, and a Test button that speaks the
3
+ // protocol to one.
4
+ //
5
+ // It sits ABOVE the credential checklist in the same tab, because it is what the credentials on that
6
+ // list are FOR: the checklist answers "which keys does this deployment want", and this answers "and
7
+ // does the server they authenticate actually work". Both are `secrets.manage`-only, which is why the
8
+ // tab is hidden rather than disabled for anyone else.
9
+ //
10
+ // A row states four independent reasons a declared server may never reach a run — no kind declares
11
+ // it, no harness can serve its transport, its credentials do not resolve, the endpoint is dead — and
12
+ // each is rendered rather than implied. Before this panel the first two lived in a boot log and the
13
+ // deployment's own source, and the last two were only visible by starting a run and reading the
14
+ // agent's prompt.
15
+ import { computed, ref } from 'vue'
16
+ import type {
17
+ ToolServerNotProbeableReason,
18
+ ToolServerProbeStatus,
19
+ ToolServerTransport,
20
+ ToolServerView,
21
+ } from '~/types/toolServers'
22
+
23
+ const { t } = useI18n()
24
+ const store = useToolServersStore()
25
+ const { present } = usePipelineErrorToast()
26
+
27
+ // Which failure DETAILS are expanded, by server id. Collapsed by default: the translated status line
28
+ // is what an operator acts on, and the raw backend prose is a disclosure behind it (never the
29
+ // primary description) per the i18n rule.
30
+ const expanded = ref<Record<string, boolean>>({})
31
+
32
+ // Exhaustive Records over the wire vocabularies, so a member added in `@cat-factory/contracts` fails
33
+ // to compile here until it has translated copy. The sanctioned guard for an enum-keyed lookup the
34
+ // typed-message-key check cannot see.
35
+ const TRANSPORT_LABELS = computed<Record<ToolServerTransport, string>>(() => ({
36
+ stdio: t('settings.toolServers.transport.stdio'),
37
+ http: t('settings.toolServers.transport.http'),
38
+ }))
39
+ const STATUS_LABELS = computed<Record<ToolServerProbeStatus, string>>(() => ({
40
+ ok: t('settings.toolServers.status.ok'),
41
+ credentials_missing: t('settings.toolServers.status.credentialsMissing'),
42
+ credential_refused: t('settings.toolServers.status.credentialRefused'),
43
+ unreachable: t('settings.toolServers.status.unreachable'),
44
+ http_error: t('settings.toolServers.status.httpError'),
45
+ protocol_error: t('settings.toolServers.status.protocolError'),
46
+ not_probeable: t('settings.toolServers.status.notProbeable'),
47
+ }))
48
+ const NOT_PROBEABLE_LABELS = computed<Record<ToolServerNotProbeableReason, string>>(() => ({
49
+ stdio_transport: t('settings.toolServers.notProbeable.stdio'),
50
+ container_local_url: t('settings.toolServers.notProbeable.containerLocal'),
51
+ url_not_allowed: t('settings.toolServers.notProbeable.urlNotAllowed'),
52
+ }))
53
+
54
+ const servers = computed<ToolServerView[]>(() => store.view?.servers ?? [])
55
+
56
+ function resultFor(id: string) {
57
+ return store.results[id]
58
+ }
59
+
60
+ /** Success is the only green; every other verdict names something for the operator to fix. */
61
+ function statusColor(status: ToolServerProbeStatus): 'success' | 'warning' | 'error' {
62
+ if (status === 'ok') return 'success'
63
+ return status === 'not_probeable' ? 'warning' : 'error'
64
+ }
65
+
66
+ async function runProbe(id: string) {
67
+ try {
68
+ await store.probe(id)
69
+ } catch (e) {
70
+ // A THROWN failure is not a verdict — a 404 for a server the deployment has since dropped, or a
71
+ // transient 5xx. Presented through the shared status-class funnel rather than stored as a
72
+ // result, so the row does not claim the probe answered.
73
+ present(e, 'settings.toolServers.toast.probeFailed')
74
+ }
75
+ }
76
+ </script>
77
+
78
+ <template>
79
+ <section v-if="servers.length" class="space-y-3" data-testid="tool-servers-section">
80
+ <div>
81
+ <h3 class="text-sm font-semibold text-slate-200">
82
+ {{ t('settings.toolServers.heading') }}
83
+ </h3>
84
+ <p class="text-xs text-slate-400">{{ t('settings.toolServers.intro') }}</p>
85
+ </div>
86
+
87
+ <article
88
+ v-for="server in servers"
89
+ :key="server.id"
90
+ class="space-y-2 rounded-lg border border-slate-700 p-3"
91
+ :data-testid="`tool-server-${server.id}`"
92
+ >
93
+ <div class="flex flex-wrap items-center gap-2">
94
+ <span class="text-sm font-medium text-slate-200">{{ server.label }}</span>
95
+ <UBadge color="neutral" variant="soft" size="sm">
96
+ {{ TRANSPORT_LABELS[server.transport] }}
97
+ </UBadge>
98
+ <code class="font-mono text-[11px] text-slate-500">{{ server.id }}</code>
99
+ </div>
100
+
101
+ <p class="truncate font-mono text-[11px] text-slate-500" :title="server.target">
102
+ {{ server.target }}
103
+ </p>
104
+ <p v-if="server.guidance" class="text-xs text-slate-400">{{ server.guidance }}</p>
105
+
106
+ <!-- Which agents get it. An EMPTY list is a registration attached to nothing: it never reaches
107
+ a dispatch, so the credentials it asks for are keys an operator fills in for no run. Said
108
+ out loud, because no other surface in the platform can see that state. -->
109
+ <p v-if="server.declaredBy.length" class="text-[11px] text-slate-400">
110
+ {{ t('settings.toolServers.declaredBy', { kinds: server.declaredBy.join(', ') }) }}
111
+ </p>
112
+ <p v-else class="text-[11px] text-amber-400" :data-testid="`tool-server-orphan-${server.id}`">
113
+ {{ t('settings.toolServers.declaredByNone') }}
114
+ </p>
115
+
116
+ <!-- Which harnesses could serve it. EMPTY means the declaration can never run anywhere (an
117
+ `http` server narrowed to Codex, whose MCP client is stdio-only): it is never dropped FOR
118
+ A REASON on any run, so no prompt and no log line ever mentions it. -->
119
+ <p v-if="server.servableHarnesses.length" class="text-[11px] text-slate-400">
120
+ {{
121
+ t('settings.toolServers.servableHarnesses', {
122
+ harnesses: server.servableHarnesses.join(', '),
123
+ })
124
+ }}
125
+ </p>
126
+ <p v-else class="text-[11px] text-amber-400">
127
+ {{ t('settings.toolServers.servableHarnessesNone') }}
128
+ </p>
129
+
130
+ <p v-if="server.allowedTools?.length" class="text-[11px] text-slate-400">
131
+ {{ t('settings.toolServers.allowedTools', { tools: server.allowedTools.join(', ') }) }}
132
+ </p>
133
+ <p v-if="server.credentials.length" class="text-[11px] text-slate-400">
134
+ {{
135
+ t('settings.toolServers.credentials', {
136
+ keys: server.credentials.map((c) => c.key).join(', '),
137
+ })
138
+ }}
139
+ </p>
140
+
141
+ <div class="flex flex-wrap items-center gap-2 pt-1">
142
+ <UButton
143
+ v-if="server.probeable"
144
+ size="xs"
145
+ variant="subtle"
146
+ icon="i-lucide-plug"
147
+ :loading="store.probing === server.id"
148
+ :disabled="store.probing !== null"
149
+ :data-testid="`tool-server-test-${server.id}`"
150
+ @click="runProbe(server.id)"
151
+ >
152
+ {{ t('settings.toolServers.test') }}
153
+ </UButton>
154
+ <!-- Not a disabled button: nothing the operator can do here makes it clickable, so the row
155
+ states the reason instead. Each reason needs a different response — nothing to fix,
156
+ verify it from a run, or change the declaration. -->
157
+ <p
158
+ v-else-if="server.notProbeableReason"
159
+ class="text-[11px] text-slate-500"
160
+ :data-testid="`tool-server-unprobeable-${server.id}`"
161
+ >
162
+ {{ NOT_PROBEABLE_LABELS[server.notProbeableReason] }}
163
+ </p>
164
+ </div>
165
+
166
+ <div
167
+ v-if="resultFor(server.id)"
168
+ class="space-y-1 rounded-md border border-slate-800 bg-slate-900/40 p-2"
169
+ :data-testid="`tool-server-result-${server.id}`"
170
+ >
171
+ <div class="flex flex-wrap items-center gap-2">
172
+ <UBadge
173
+ :color="statusColor(resultFor(server.id)!.status)"
174
+ variant="soft"
175
+ size="sm"
176
+ :data-testid="`tool-server-status-${server.id}`"
177
+ >
178
+ {{ STATUS_LABELS[resultFor(server.id)!.status] }}
179
+ </UBadge>
180
+ <span v-if="resultFor(server.id)!.httpStatus" class="text-[11px] text-slate-400">
181
+ {{ t('settings.toolServers.httpStatus', { status: resultFor(server.id)!.httpStatus }) }}
182
+ </span>
183
+ </div>
184
+
185
+ <p v-if="resultFor(server.id)!.status === 'ok'" class="text-[11px] text-slate-300">
186
+ {{
187
+ t('settings.toolServers.okDetail', {
188
+ name: resultFor(server.id)!.serverName || server.id,
189
+ version: resultFor(server.id)!.serverVersion || '?',
190
+ protocol: resultFor(server.id)!.protocolVersion ?? '?',
191
+ count: resultFor(server.id)!.toolCount ?? 0,
192
+ })
193
+ }}
194
+ </p>
195
+ <!-- A count off a truncated read is a FLOOR, not a total, and the difference decides whether
196
+ the allowedTools verdict below means anything. -->
197
+ <p v-if="resultFor(server.id)!.toolsComplete === false" class="text-[11px] text-slate-500">
198
+ {{ t('settings.toolServers.toolsIncomplete') }}
199
+ </p>
200
+
201
+ <!-- The reconciliation nothing else in the platform can do: a well-formed tool name that
202
+ matches nothing narrows the CLI's allow-list to a dead pattern while the prompt keeps
203
+ advertising the tool. Withheld entirely when the tool list was a prefix. -->
204
+ <p
205
+ v-if="resultFor(server.id)!.allowedTools?.unmatched?.length"
206
+ class="text-[11px] text-amber-400"
207
+ :data-testid="`tool-server-unmatched-${server.id}`"
208
+ >
209
+ {{
210
+ t('settings.toolServers.unmatchedTools', {
211
+ tools: resultFor(server.id)!.allowedTools!.unmatched.join(', '),
212
+ })
213
+ }}
214
+ </p>
215
+ <p
216
+ v-else-if="resultFor(server.id)!.allowedTools?.checked === false"
217
+ class="text-[11px] text-slate-500"
218
+ >
219
+ {{ t('settings.toolServers.allowedToolsUnchecked') }}
220
+ </p>
221
+
222
+ <p
223
+ v-if="resultFor(server.id)!.unresolvedCredentials?.length"
224
+ class="text-[11px] text-amber-400"
225
+ >
226
+ {{
227
+ t('settings.toolServers.unresolvedCredentials', {
228
+ keys: resultFor(server.id)!.unresolvedCredentials!.join(', '),
229
+ })
230
+ }}
231
+ </p>
232
+ <p v-if="resultFor(server.id)!.refusedCredentials?.length" class="text-[11px] text-red-400">
233
+ {{
234
+ t('settings.toolServers.refusedCredentials', {
235
+ keys: resultFor(server.id)!.refusedCredentials!.join(', '),
236
+ })
237
+ }}
238
+ </p>
239
+
240
+ <!-- Raw backend prose is DETAIL behind a disclosure, never the primary description. It is
241
+ already scrubbed through `redactSecrets` at the emit site. -->
242
+ <template v-if="resultFor(server.id)!.error">
243
+ <UButton
244
+ size="xs"
245
+ variant="link"
246
+ class="px-0"
247
+ :data-testid="`tool-server-details-${server.id}`"
248
+ @click="expanded[server.id] = !expanded[server.id]"
249
+ >
250
+ {{
251
+ expanded[server.id]
252
+ ? t('settings.toolServers.hideDetails')
253
+ : t('settings.toolServers.showDetails')
254
+ }}
255
+ </UButton>
256
+ <pre
257
+ v-if="expanded[server.id]"
258
+ class="overflow-x-auto rounded bg-slate-950 p-2 font-mono text-[10px] text-slate-400"
259
+ >{{ resultFor(server.id)!.error }}</pre>
260
+ </template>
261
+ </div>
262
+ </article>
263
+ </section>
264
+ </template>
@@ -74,6 +74,7 @@ const routes = reactive<Record<NotificationType, SlackRoute>>({
74
74
  initiative: { enabled: false, channel: '' },
75
75
  platform_health: { enabled: false, channel: '' },
76
76
  budget_paused: { enabled: false, channel: '' },
77
+ budget_threshold: { enabled: false, channel: '' },
77
78
  // In-app only (not in ROUTABLE), but the map is exhaustive over the type.
78
79
  key_drift: { enabled: false, channel: '' },
79
80
  infra_unreachable: { enabled: false, channel: '' },
@@ -0,0 +1,21 @@
1
+ import { listToolServersContract, probeToolServerContract } from '@cat-factory/contracts'
2
+ import type { ApiContext } from './context'
3
+
4
+ /**
5
+ * Per-workspace tool-server (MCP) operability: the inventory of what this deployment declared, and
6
+ * a probe that speaks the protocol to one of them.
7
+ *
8
+ * `secrets.manage`-gated end to end, the READ included — the inventory names the credential keys the
9
+ * deployment's capabilities want and the endpoints those credentials are sent to. The probe is a
10
+ * POST because it SPENDS an outbound request under the deployment's own credential, so it must not
11
+ * be safe to retry from a cache or a prefetch. See ToolServerController.
12
+ */
13
+ export function toolServersApi({ send, ws }: ApiContext) {
14
+ return {
15
+ listToolServers: (workspaceId: string) =>
16
+ send(listToolServersContract, { pathPrefix: ws(workspaceId) }),
17
+
18
+ probeToolServer: (workspaceId: string, id: string) =>
19
+ send(probeToolServerContract, { pathPrefix: ws(workspaceId), pathParams: { id } }),
20
+ }
21
+ }
@@ -33,6 +33,7 @@ import { modelsApi } from './api/models'
33
33
  import { notificationsApi } from './api/notifications'
34
34
  import { packageRegistriesApi } from './api/packageRegistries'
35
35
  import { capabilityCredentialsApi } from './api/capabilityCredentials'
36
+ import { toolServersApi } from './api/toolServers'
36
37
  import { preflightsApi } from './api/preflights'
37
38
  import { presetsApi } from './api/presets'
38
39
  import { publicApiKeysApi } from './api/publicApiKeys'
@@ -156,6 +157,7 @@ export function useApi() {
156
157
  ...testSecretsApi(ctx),
157
158
  ...packageRegistriesApi(ctx),
158
159
  ...capabilityCredentialsApi(ctx),
160
+ ...toolServersApi(ctx),
159
161
  ...previewApi(ctx),
160
162
  ...environmentsApi(ctx),
161
163
  ...recurringApi(ctx),
@@ -0,0 +1,142 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+ import { useToolServersStore } from '~/stores/toolServers'
3
+ import { useWorkspaceStore } from '~/stores/workspace'
4
+ import type { ToolServerProbeResult, ToolServerView } from '~/types/toolServers'
5
+
6
+ /**
7
+ * Three behaviours carry this store, each about not losing an answer:
8
+ *
9
+ * - the availability probe. A 403 ("you may not manage secrets") is an ANSWER and resolves
10
+ * normally, hiding the surface; anything else propagates, because the panel is what can tell a
11
+ * reader the list could not be fetched. There is deliberately no 503 case, unlike the
12
+ * credential store: projecting a registry needs no encryption key.
13
+ * - a probe result is kept for every VERDICT, failures included. A failure is exactly the answer
14
+ * the operator asked for, and a store that only kept successes would leave the row looking
15
+ * untouched after a dead endpoint was reported.
16
+ * - results survive a re-read of the inventory, because a result describes the SERVER rather than
17
+ * the list it arrived in.
18
+ */
19
+ function server(over: Partial<ToolServerView> = {}): ToolServerView {
20
+ return {
21
+ id: 'issues',
22
+ label: 'Issue tracker',
23
+ transport: 'http',
24
+ target: 'https://mcp.example/rpc',
25
+ declaredBy: ['coder'],
26
+ servableHarnesses: ['claude-code'],
27
+ credentials: [],
28
+ probeable: true,
29
+ ...over,
30
+ }
31
+ }
32
+
33
+ describe('toolServers store', () => {
34
+ beforeEach(() => {
35
+ useWorkspaceStore().workspaceId = 'ws1'
36
+ })
37
+
38
+ it('load stores the inventory and marks the surface available', async () => {
39
+ vi.stubGlobal('useApi', () => ({
40
+ listToolServers: () => Promise.resolve({ servers: [server()] }),
41
+ }))
42
+
43
+ const store = useToolServersStore()
44
+ await store.load()
45
+
46
+ expect(store.available).toBe(true)
47
+ expect(store.hasSurface).toBe(true)
48
+ expect(store.loading).toBe(false)
49
+ })
50
+
51
+ it('shares one read between concurrent callers', async () => {
52
+ let reads = 0
53
+ vi.stubGlobal('useApi', () => ({
54
+ listToolServers: () => {
55
+ reads++
56
+ return Promise.resolve({ servers: [server()] })
57
+ },
58
+ }))
59
+
60
+ const store = useToolServersStore()
61
+ // Both callers fire on the same interaction: the Infrastructure window asks `ensureLoaded` whether
62
+ // the tab exists at all, and the panel refreshes on mount so a redeploy shows up without a reload.
63
+ // A read that started microseconds ago IS that refresh, so it is shared rather than duplicated.
64
+ await Promise.all([store.ensureLoaded(), store.load()])
65
+
66
+ expect(reads).toBe(1)
67
+ expect(store.hasSurface).toBe(true)
68
+ })
69
+
70
+ it('a 403 latches the surface unavailable without throwing', async () => {
71
+ vi.stubGlobal('useApi', () => ({
72
+ listToolServers: () => Promise.reject({ statusCode: 403 }),
73
+ }))
74
+
75
+ const store = useToolServersStore()
76
+ await expect(store.load()).resolves.toBeUndefined()
77
+
78
+ expect(store.available).toBe(false)
79
+ expect(store.hasSurface).toBe(false)
80
+ })
81
+
82
+ it('a transient failure propagates and latches nothing', async () => {
83
+ vi.stubGlobal('useApi', () => ({
84
+ listToolServers: () => Promise.reject({ statusCode: 500 }),
85
+ }))
86
+
87
+ const store = useToolServersStore()
88
+ await expect(store.load()).rejects.toBeDefined()
89
+
90
+ // `available` stays null so `ensureLoaded` remains retryable, and an already-loaded surface is
91
+ // not hidden by one bad read.
92
+ expect(store.available).toBeNull()
93
+ })
94
+
95
+ it('reports no surface for a deployment that registers no tool server', async () => {
96
+ vi.stubGlobal('useApi', () => ({ listToolServers: () => Promise.resolve({ servers: [] }) }))
97
+
98
+ const store = useToolServersStore()
99
+ await store.load()
100
+
101
+ // An answer rather than possibly an outage: the inventory is read off this process's own
102
+ // registry, which is why there is no `declarationsIncomplete` counterpart here.
103
+ expect(store.available).toBe(true)
104
+ expect(store.hasSurface).toBe(false)
105
+ })
106
+
107
+ it('keeps a FAILING probe result, and keeps it across a re-read of the inventory', async () => {
108
+ const failure: ToolServerProbeResult = {
109
+ serverId: 'issues',
110
+ status: 'unreachable',
111
+ error: 'TypeError: fetch failed',
112
+ }
113
+ vi.stubGlobal('useApi', () => ({
114
+ listToolServers: () => Promise.resolve({ servers: [server()] }),
115
+ probeToolServer: () => Promise.resolve(failure),
116
+ }))
117
+
118
+ const store = useToolServersStore()
119
+ await store.load()
120
+ await store.probe('issues')
121
+
122
+ expect(store.results.issues).toEqual(failure)
123
+ expect(store.probing).toBeNull()
124
+
125
+ await store.load()
126
+ expect(store.results.issues).toEqual(failure)
127
+ })
128
+
129
+ it('propagates a THROWN probe failure without storing a verdict', async () => {
130
+ // A 404 for a server the deployment has since dropped, or a transient 5xx: not a verdict, so the
131
+ // row must not claim the probe answered.
132
+ vi.stubGlobal('useApi', () => ({
133
+ probeToolServer: () => Promise.reject({ statusCode: 404 }),
134
+ }))
135
+
136
+ const store = useToolServersStore()
137
+ await expect(store.probe('ghost')).rejects.toBeDefined()
138
+
139
+ expect(store.results.ghost).toBeUndefined()
140
+ expect(store.probing).toBeNull()
141
+ })
142
+ })