@cat-factory/app 0.222.0 → 0.224.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.
@@ -64,6 +64,10 @@ const ISSUE_KEYS = {
64
64
  title: 'inputGate.issue.success_criteria_missing.title',
65
65
  hint: 'inputGate.issue.success_criteria_missing.hint',
66
66
  },
67
+ required_field_missing: {
68
+ title: 'inputGate.issue.required_field_missing.title',
69
+ hint: 'inputGate.issue.required_field_missing.hint',
70
+ },
67
71
  } as const satisfies Record<InputGateIssueCode, { title: string; hint: string }>
68
72
 
69
73
  /**
@@ -87,16 +91,30 @@ const TONE_COPY: Record<InputGateTone, { title: string; body: string }> = {
87
91
  }
88
92
  const copy = computed(() => TONE_COPY[props.tone])
89
93
 
94
+ /**
95
+ * The interpolation a finding's copy is rendered with. Only `required_field_missing` carries a
96
+ * `field`, and its copy is the one line that cannot be written without knowing which input is
97
+ * missing: a deployment registers its own task types, so the platform has no vocabulary for
98
+ * "the incident's severity" and names the field instead. The label is deployment-supplied
99
+ * English, exactly as a custom agent kind's is.
100
+ *
101
+ * A finding whose `field` is somehow absent still renders: the copy falls back to naming no
102
+ * field rather than printing `undefined` into a sentence a human is meant to act on.
103
+ */
104
+ function issueValues(issue: InputGateIssue): Record<string, string> {
105
+ return { field: issue.field?.label ?? t('inputGate.issue.required_field_missing.unnamedField') }
106
+ }
107
+
90
108
  /** A finding's translated title, falling back to the generic line for a retired code. */
91
- function issueTitle(code: InputGateIssueCode): string {
92
- const key = ISSUE_KEYS[code]?.title
93
- return key && te(key) ? t(key) : t('inputGate.issue.unknown.title')
109
+ function issueTitle(issue: InputGateIssue): string {
110
+ const key = ISSUE_KEYS[issue.code]?.title
111
+ return key && te(key) ? t(key, issueValues(issue)) : t('inputGate.issue.unknown.title')
94
112
  }
95
113
 
96
114
  /** A finding's translated remedy hint, on the same fallback. */
97
- function issueHint(code: InputGateIssueCode): string {
98
- const key = ISSUE_KEYS[code]?.hint
99
- return key && te(key) ? t(key) : t('inputGate.issue.unknown.hint')
115
+ function issueHint(issue: InputGateIssue): string {
116
+ const key = ISSUE_KEYS[issue.code]?.hint
117
+ return key && te(key) ? t(key, issueValues(issue)) : t('inputGate.issue.unknown.hint')
100
118
  }
101
119
 
102
120
  async function resolve(choice: 'recheck' | 'proceed') {
@@ -126,7 +144,11 @@ async function resolve(choice: 'recheck' | 'proceed') {
126
144
  <p v-if="!compact" class="text-muted mt-0.5 text-xs">{{ t(copy.body) }}</p>
127
145
 
128
146
  <ul class="mt-2 space-y-1.5">
129
- <li v-for="issue in issues" :key="issue.code" class="flex items-start gap-2 text-xs">
147
+ <li
148
+ v-for="issue in issues"
149
+ :key="`${issue.code}:${issue.field?.key ?? ''}`"
150
+ class="flex items-start gap-2 text-xs"
151
+ >
130
152
  <UBadge
131
153
  :color="issue.severity === 'blocking' ? 'warning' : 'neutral'"
132
154
  variant="subtle"
@@ -139,8 +161,8 @@ async function resolve(choice: 'recheck' | 'proceed') {
139
161
  }}
140
162
  </UBadge>
141
163
  <span class="min-w-0">
142
- <span class="font-medium">{{ issueTitle(issue.code) }}</span>
143
- <span class="text-muted">, {{ issueHint(issue.code) }}</span>
164
+ <span class="font-medium">{{ issueTitle(issue) }}</span>
165
+ <span class="text-muted">, {{ issueHint(issue) }}</span>
144
166
  </span>
145
167
  </li>
146
168
  </ul>
@@ -0,0 +1,115 @@
1
+ <script setup lang="ts">
2
+ // The answers to a CUSTOM task type's own declared fields, editable after creation.
3
+ //
4
+ // Why it exists: the create form is not the only door a task arrives through (the public API, an
5
+ // initiative spawn, a tracker import), and a type's declaration can get STRICTER after a task
6
+ // already exists. The pre-dispatch input gate judges the declaration as it stands now, so it
7
+ // parks runs whose task predates the requirement. Without this panel that park had exactly one
8
+ // exit, a human waiving the gate: `recheck` would re-read the same unanswered bag forever, and
9
+ // the remedy the notice names ("fill it in on the task") would be one nothing offered.
10
+ //
11
+ // Renders through the SAME `DescriptorFields` component the create form uses, against the SAME
12
+ // declaration, validated by the SAME shared rule. A field the form would have hidden by its
13
+ // `showWhen` is hidden here too, so the two doors cannot show a person different questions.
14
+ import { computed, ref, watch } from 'vue'
15
+ import type { DescriptorFieldValues } from '@cat-factory/contracts'
16
+ import { sanitizeDescriptorFields, validateDescriptorFields } from '@cat-factory/contracts'
17
+ import type { Block } from '~/types/domain'
18
+ import DescriptorFields from '~/components/common/DescriptorFields.vue'
19
+ import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
20
+
21
+ const props = defineProps<{ block: Block }>()
22
+
23
+ const board = useBoardStore()
24
+ const taskTypes = useTaskTypesStore()
25
+ const { t } = useI18n()
26
+
27
+ /** The registered type this task is, or undefined for a built-in / unregistered one. */
28
+ const descriptor = computed(() =>
29
+ props.block.taskType
30
+ ? taskTypes.customTaskTypes.find((tt) => tt.taskType === props.block.taskType)
31
+ : undefined,
32
+ )
33
+
34
+ /**
35
+ * The fields to render. A type carrying a bespoke `formPanel` owns its whole bag, so its
36
+ * descriptor fields are not what was collected and editing them here would write values its own
37
+ * form never offered. That is the same stand-down the create door and the input gate take, and
38
+ * all three have to agree or "the declaration" would mean three different things.
39
+ */
40
+ const fields = computed(() => (descriptor.value?.formPanel ? [] : (descriptor.value?.fields ?? [])))
41
+
42
+ const stored = computed<DescriptorFieldValues>(() => props.block.taskTypeFields?.custom ?? {})
43
+
44
+ // Local edit buffer, re-seeded whenever the stored bag changes underneath (a live board push, or
45
+ // switching blocks). Editing writes on commit rather than per keystroke, so a half-typed answer
46
+ // never reaches the row the gate reads.
47
+ const draft = ref<DescriptorFieldValues>({ ...stored.value })
48
+ watch(stored, (next) => {
49
+ draft.value = { ...next }
50
+ })
51
+
52
+ /**
53
+ * The same check the server runs, so the button reflects an invalid form rather than the save
54
+ * failing with a 422. Shared from contracts precisely so the two cannot drift.
55
+ */
56
+ const problems = computed(() => validateDescriptorFields(fields.value, draft.value))
57
+
58
+ const dirty = computed(
59
+ () =>
60
+ JSON.stringify(sanitizeDescriptorFields(fields.value, draft.value)) !==
61
+ JSON.stringify(stored.value),
62
+ )
63
+
64
+ const saving = ref(false)
65
+
66
+ async function save() {
67
+ if (problems.value.length || !dirty.value) return
68
+ saving.value = true
69
+ try {
70
+ await board.updateBlock(props.block.id, {
71
+ customTaskTypeFields: sanitizeDescriptorFields(fields.value, draft.value),
72
+ })
73
+ } finally {
74
+ saving.value = false
75
+ }
76
+ }
77
+
78
+ function revert() {
79
+ draft.value = { ...stored.value }
80
+ }
81
+ </script>
82
+
83
+ <template>
84
+ <InspectorSection
85
+ v-if="fields.length"
86
+ :title="t('inspector.taskTypeFields.title')"
87
+ :hint="t('inspector.taskTypeFields.hint')"
88
+ icon="i-lucide-clipboard-list"
89
+ :count="fields.length"
90
+ >
91
+ <DescriptorFields v-model="draft" :fields="fields" testid-prefix="task-type-field" />
92
+ <div v-if="dirty" class="mt-2 flex items-center gap-2">
93
+ <UButton
94
+ size="xs"
95
+ color="primary"
96
+ variant="soft"
97
+ :loading="saving"
98
+ :disabled="problems.length > 0"
99
+ data-testid="task-type-fields-save"
100
+ @click="save"
101
+ >
102
+ {{ t('inspector.taskTypeFields.save') }}
103
+ </UButton>
104
+ <UButton
105
+ size="xs"
106
+ color="neutral"
107
+ variant="ghost"
108
+ data-testid="task-type-fields-revert"
109
+ @click="revert"
110
+ >
111
+ {{ t('inspector.taskTypeFields.revert') }}
112
+ </UButton>
113
+ </div>
114
+ </InspectorSection>
115
+ </template>
@@ -88,6 +88,7 @@ onBeforeUnmount(() => {
88
88
  const OPERATION_LABEL = computed<Record<ProvisioningOperation, string>>(() => ({
89
89
  provision: t('provisioning.operation.provision'),
90
90
  teardown: t('provisioning.operation.teardown'),
91
+ 'teardown-verify': t('provisioning.operation.teardown-verify'),
91
92
  status: t('provisioning.operation.status'),
92
93
  dispatch: t('provisioning.operation.dispatch'),
93
94
  release: t('provisioning.operation.release'),
@@ -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>
@@ -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),
@@ -97,6 +97,11 @@ describe('inspector panel group', () => {
97
97
  'task-dependencies',
98
98
  'task-run-settings',
99
99
  'task-agent-config',
100
+ // The custom type's own declared fields sit with the other task INPUTS (what the task is),
101
+ // not under run settings (how it runs). It is gated on being a task alone: the panel hides
102
+ // itself unless the type is one this deployment registered with descriptor fields, which
103
+ // the spec here cannot see and should not try to.
104
+ 'task-type-fields',
100
105
  'task-structure',
101
106
  ])
102
107
  })
@@ -45,6 +45,7 @@ export const INSPECTOR_PANEL_IDS = [
45
45
  'task-dependencies',
46
46
  'task-run-settings',
47
47
  'task-agent-config',
48
+ 'task-type-fields',
48
49
  'task-structure',
49
50
  // service / module body
50
51
  'container-summary',
@@ -134,6 +135,11 @@ export const INSPECTOR_PANEL_SPECS: readonly InspectorPanelSpec[] = [
134
135
  { id: 'task-dependencies', order: 60, when: isTask },
135
136
  { id: 'task-run-settings', order: 70, when: isTask },
136
137
  { id: 'task-agent-config', order: 80, when: isTask },
138
+ // The answers to a CUSTOM task type's declared fields. Gated on being a task alone; the panel
139
+ // itself hides unless the task's type is one this deployment registered WITH descriptor fields,
140
+ // which is the only case there is anything to edit. It sits beside the other task inputs rather
141
+ // than under Run settings: these are what the task IS, not how it runs.
142
+ { id: 'task-type-fields', order: 85, when: isTask },
137
143
  { id: 'task-structure', order: 90, when: isTask },
138
144
  { id: 'container-summary', order: 110, when: isContainer },
139
145
  { id: 'frontend-config', order: 120, when: (b) => isFrame(b) && b.type === 'frontend' },
@@ -21,6 +21,7 @@ import TaskEstimateBadge from '~/components/panels/inspector/TaskEstimateBadge.v
21
21
  import TaskDependencies from '~/components/panels/inspector/TaskDependencies.vue'
22
22
  import TaskRunSettings from '~/components/panels/inspector/TaskRunSettings.vue'
23
23
  import TaskAgentConfig from '~/components/panels/inspector/TaskAgentConfig.vue'
24
+ import TaskTypeFields from '~/components/panels/inspector/TaskTypeFields.vue'
24
25
  import TaskStructure from '~/components/panels/inspector/TaskStructure.vue'
25
26
  import ContainerSummary from '~/components/panels/inspector/ContainerSummary.vue'
26
27
  import FrontendConfig from '~/components/panels/inspector/FrontendConfig.vue'
@@ -75,6 +76,7 @@ const COMPONENTS: Record<InspectorPanelId, Component> = {
75
76
  'task-dependencies': TaskDependencies,
76
77
  'task-run-settings': TaskRunSettings,
77
78
  'task-agent-config': TaskAgentConfig,
79
+ 'task-type-fields': TaskTypeFields,
78
80
  'task-structure': TaskStructure,
79
81
  'container-summary': ContainerSummary,
80
82
  'frontend-config': FrontendConfig,