@frockbot/plugin-settings 0.0.0 → 0.1.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 (36) hide show
  1. package/frockbot.json +55 -0
  2. package/package.json +43 -6
  3. package/src/backend.test.ts +397 -0
  4. package/src/backend.ts +262 -0
  5. package/src/client/BotPanel.vue +57 -0
  6. package/src/client/BotSettingsSurface.vue +1076 -0
  7. package/src/client/BotSettingsTrigger.vue +26 -0
  8. package/src/client/ConnectionsSurface.vue +813 -0
  9. package/src/client/ModelsSurface.vue +419 -0
  10. package/src/client/PackageAccounts.vue +330 -0
  11. package/src/client/PackageCatalogSurface.vue +584 -0
  12. package/src/client/PackageSettingsForm.vue +150 -0
  13. package/src/client/PackageSettingsSection.vue +66 -0
  14. package/src/client/PluginsSurface.vue +412 -0
  15. package/src/client/PluginsTrigger.vue +62 -0
  16. package/src/client/UserProfileTrigger.vue +223 -0
  17. package/src/client/UserSettingsSurface.vue +242 -0
  18. package/src/client/assignment-operations.ts +22 -0
  19. package/src/client/bot-settings.test.ts +218 -0
  20. package/src/client/bot-settings.ts +150 -0
  21. package/src/client/index.test.ts +113 -0
  22. package/src/client/index.ts +78 -0
  23. package/src/client/package-settings.test.ts +63 -0
  24. package/src/client/package-settings.ts +77 -0
  25. package/src/client/package-surfaces.test.ts +147 -0
  26. package/src/client/package-surfaces.ts +93 -0
  27. package/src/client/user-display-name.test.ts +44 -0
  28. package/src/client/user-display-name.ts +16 -0
  29. package/src/env.d.ts +6 -0
  30. package/src/index.ts +1 -0
  31. package/src/manifest.ts +3 -0
  32. package/src/user.test.ts +1277 -0
  33. package/src/user.ts +1221 -0
  34. package/tsconfig.json +15 -0
  35. package/vite.config.ts +30 -0
  36. package/README.md +0 -3
@@ -0,0 +1,813 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Connections: the accounts and services a User authorizes and may then give a
4
+ * Bot access to — Composio, Gmail, a remote MCP server.
5
+ *
6
+ * It holds authorization and its durable state only. Whether the Package
7
+ * providing a connector is enabled at all is Plugins' question, and a model
8
+ * provider's account belongs to Models even though it is a Connection too.
9
+ */
10
+ import { clientSurfaceRegistryKey } from "@frockbot/client-core";
11
+ import { UiAnchor, UiButton } from "@frockbot/client-ui";
12
+ import {
13
+ frockBotWebDataKey,
14
+ type PluginCatalogItem,
15
+ } from "@frockbot/plugin-shell/shared";
16
+ import { settingsLinkV1 } from "@frockbot/plugin-shell/settings-links";
17
+ import { computed, inject, onMounted, ref } from "vue";
18
+ import { configurablePackages } from "./package-surfaces.js";
19
+ import PackageAccounts from "./PackageAccounts.vue";
20
+ import PackageSettingsForm from "./PackageSettingsForm.vue";
21
+
22
+ const providedSurfaces = inject(clientSurfaceRegistryKey);
23
+ const providedWeb = inject(frockBotWebDataKey);
24
+ if (!providedSurfaces || !providedWeb) {
25
+ throw new Error("settings client services were not provided");
26
+ }
27
+ const surfaces = providedSurfaces;
28
+ const web = providedWeb;
29
+ // Connections are User-scoped, so the row's link names no Bot.
30
+ const connectionsLink = settingsLinkV1({ anchor: "user-connections" });
31
+
32
+ const connectors = computed(() =>
33
+ configurablePackages({
34
+ catalog: web.value.pluginCatalog,
35
+ packages: web.value.userSettings?.packages ?? [],
36
+ home: "connections",
37
+ }),
38
+ );
39
+
40
+ /**
41
+ * The connect cards. Drawn from the Connection projection — which carries no
42
+ * URL — so a card can be shown by anything that can read Connections, while
43
+ * the redirect is authored only when the User presses *Reconnect*.
44
+ */
45
+ const pendingAuthorizations = computed(() =>
46
+ (web.value.userSettings?.connections ?? []).filter(
47
+ (connection) =>
48
+ connection.pendingAuthorization !== undefined &&
49
+ connection.state !== "revoked",
50
+ ),
51
+ );
52
+ const reconnectingConnectionId = ref<string>();
53
+
54
+ const apiKeyPackageId = ref<string>();
55
+ const apiKeyConnectionTypeId = ref<string>();
56
+ const apiKeyLabel = ref("");
57
+ const apiKey = ref("");
58
+
59
+ /**
60
+ * The MCP server form. A remote MCP server is a Connection like any other, but
61
+ * it needs its URL and transport as well as an optional key, so the generic
62
+ * API-key form cannot carry it. Both commands it can send are the ordinary
63
+ * Connection commands: `connection/create-api-key` when a key is given, and
64
+ * `connection/create` when the server is public.
65
+ */
66
+ const MCP_PACKAGE_ID = "mcp";
67
+ const mcpFormOpen = ref(false);
68
+ const mcpLabel = ref("");
69
+ const mcpUrl = ref("");
70
+ const mcpTransport = ref<"streamable-http" | "sse">("streamable-http");
71
+ const mcpApiKey = ref("");
72
+ /**
73
+ * How the custom server authenticates. `oauth` is not a third field on the
74
+ * same form but a different command: it starts an authorization the host
75
+ * authors a redirect for, rather than creating a Connection from a secret the
76
+ * User pasted.
77
+ */
78
+ const mcpAuthMode = ref<"none" | "key" | "oauth">("none");
79
+ const mcpScope = ref("");
80
+ const mcpClientId = ref("");
81
+
82
+ /**
83
+ * The MCP lifecycle panel: the durable server records the User Durable Object
84
+ * owns, which the Connection rows cannot show — a server's `needs-auth` or
85
+ * `error` state, when it last handshook, what its instructions are, and the
86
+ * refusals this build recorded rather than performed.
87
+ */
88
+ const editingInstructionsFor = ref<string>();
89
+ const instructionsDraft = ref("");
90
+ const restartingServerId = ref<string>();
91
+ const mcpServers = computed(() => web.value.mcpServers?.servers ?? []);
92
+ const mcpRefusals = computed(() => web.value.mcpServers?.refusals ?? []);
93
+
94
+ onMounted(() => {
95
+ void web.value.loadPluginCatalog();
96
+ void web.value.loadMcpServers();
97
+ });
98
+
99
+ function mcpStateLabel(state: string): string {
100
+ if (state === "needs-auth") return "Needs authorization";
101
+ if (state === "connecting") return "Connecting";
102
+ if (state === "error") return "Error";
103
+ return "Ready";
104
+ }
105
+
106
+ function handshakeLabel(value: string): string {
107
+ const parsed = Date.parse(value);
108
+ return Number.isFinite(parsed) ? new Date(parsed).toLocaleString() : value;
109
+ }
110
+
111
+ function beginInstructions(serverId: string, instructions: string): void {
112
+ editingInstructionsFor.value = serverId;
113
+ instructionsDraft.value = instructions;
114
+ }
115
+
116
+ function cancelInstructions(): void {
117
+ editingInstructionsFor.value = undefined;
118
+ instructionsDraft.value = "";
119
+ }
120
+
121
+ async function saveInstructions(serverId: string): Promise<void> {
122
+ await web.value.setMcpInstructions(serverId, instructionsDraft.value);
123
+ cancelInstructions();
124
+ }
125
+
126
+ async function restartServer(serverId: string): Promise<void> {
127
+ restartingServerId.value = serverId;
128
+ try {
129
+ await web.value.restartMcpServer(serverId);
130
+ } finally {
131
+ restartingServerId.value = undefined;
132
+ }
133
+ }
134
+
135
+ /**
136
+ * *Reconnect*: the authenticated command that mints a fresh redirect, with
137
+ * fresh PKCE and a fresh signed state. Nothing was stored waiting for this —
138
+ * the card only ever said that a decision was pending.
139
+ */
140
+ async function reconnect(connectionId: string): Promise<void> {
141
+ reconnectingConnectionId.value = connectionId;
142
+ try {
143
+ const redirect = await web.value.startMcpAuthorization({ connectionId });
144
+ if (redirect) await web.value.openConnectionAuthorization(redirect);
145
+ } catch (error) {
146
+ web.value.settingsError =
147
+ error instanceof Error ? error.message : "Could not reconnect";
148
+ } finally {
149
+ reconnectingConnectionId.value = undefined;
150
+ }
151
+ }
152
+
153
+ function connectionCount(packageId: string): number {
154
+ return (web.value.userSettings?.connections ?? []).filter(
155
+ (connection) =>
156
+ connection.packageId === packageId && connection.state !== "revoked",
157
+ ).length;
158
+ }
159
+
160
+ function beginMcpConnection(): void {
161
+ mcpFormOpen.value = true;
162
+ mcpLabel.value = "";
163
+ mcpUrl.value = "";
164
+ mcpTransport.value = "streamable-http";
165
+ mcpApiKey.value = "";
166
+ mcpAuthMode.value = "none";
167
+ mcpScope.value = "";
168
+ mcpClientId.value = "";
169
+ }
170
+
171
+ function cancelMcpConnection(): void {
172
+ mcpFormOpen.value = false;
173
+ mcpApiKey.value = "";
174
+ mcpAuthMode.value = "none";
175
+ }
176
+
177
+ async function addMcpServer(): Promise<void> {
178
+ const settings = {
179
+ url: mcpUrl.value.trim(),
180
+ transport: mcpTransport.value,
181
+ };
182
+ try {
183
+ if (mcpAuthMode.value === "oauth") {
184
+ // No secret is collected and none is stored: the server is discovered,
185
+ // a client is registered, and the redirect is authored by the host.
186
+ const redirect = await web.value.startMcpAuthorization({
187
+ label: mcpLabel.value,
188
+ settings: {
189
+ ...settings,
190
+ ...(mcpScope.value.trim() ? { scope: mcpScope.value.trim() } : {}),
191
+ ...(mcpClientId.value.trim()
192
+ ? { "client-id": mcpClientId.value.trim() }
193
+ : {}),
194
+ },
195
+ });
196
+ cancelMcpConnection();
197
+ if (redirect) await web.value.openConnectionAuthorization(redirect);
198
+ return;
199
+ }
200
+ if (mcpApiKey.value) {
201
+ await web.value.createApiKeyConnection({
202
+ packageId: MCP_PACKAGE_ID,
203
+ connectionTypeId: "mcp-remote-key",
204
+ label: mcpLabel.value,
205
+ apiKey: mcpApiKey.value,
206
+ settings,
207
+ });
208
+ } else {
209
+ await web.value.createConnection({
210
+ packageId: MCP_PACKAGE_ID,
211
+ connectionTypeId: "mcp-remote",
212
+ label: mcpLabel.value,
213
+ settings,
214
+ });
215
+ }
216
+ cancelMcpConnection();
217
+ } catch (error) {
218
+ mcpApiKey.value = "";
219
+ web.value.settingsError =
220
+ error instanceof Error ? error.message : "Could not add the MCP server";
221
+ }
222
+ }
223
+
224
+ /** Start whichever authorization the card's Connection Type declares. */
225
+ function beginConnect(item: PluginCatalogItem): void {
226
+ if (item.packageId === MCP_PACKAGE_ID) {
227
+ beginMcpConnection();
228
+ return;
229
+ }
230
+ const connectionType = item.connectionTypes[0];
231
+ if (!connectionType) return;
232
+ if (connectionType.authorizationKind === "api-key") {
233
+ apiKeyPackageId.value = item.packageId;
234
+ apiKeyConnectionTypeId.value = connectionType.id;
235
+ apiKeyLabel.value = item.displayName;
236
+ apiKey.value = "";
237
+ return;
238
+ }
239
+ void connect(item.packageId, connectionType.id);
240
+ }
241
+
242
+ function cancelApiKeyConnection(): void {
243
+ apiKey.value = "";
244
+ apiKeyPackageId.value = undefined;
245
+ apiKeyConnectionTypeId.value = undefined;
246
+ }
247
+
248
+ async function connectApiKey(): Promise<void> {
249
+ if (!apiKeyPackageId.value || !apiKeyConnectionTypeId.value) return;
250
+ try {
251
+ await web.value.createApiKeyConnection({
252
+ packageId: apiKeyPackageId.value,
253
+ connectionTypeId: apiKeyConnectionTypeId.value,
254
+ label: apiKeyLabel.value,
255
+ apiKey: apiKey.value,
256
+ });
257
+ cancelApiKeyConnection();
258
+ } catch (error) {
259
+ apiKey.value = "";
260
+ web.value.settingsError =
261
+ error instanceof Error ? error.message : "Could not create Connection";
262
+ }
263
+ }
264
+
265
+ async function connect(
266
+ packageId: string,
267
+ connectionTypeId: string,
268
+ ): Promise<void> {
269
+ try {
270
+ const redirectUrl = await web.value.startConnection(
271
+ packageId,
272
+ connectionTypeId,
273
+ );
274
+ if (redirectUrl) {
275
+ await web.value.openConnectionAuthorization(redirectUrl);
276
+ }
277
+ await web.value.loadPluginCatalog();
278
+ } catch (error) {
279
+ web.value.settingsError =
280
+ error instanceof Error ? error.message : "Could not start Connection";
281
+ }
282
+ }
283
+ </script>
284
+
285
+ <template>
286
+ <div class="connections-surface">
287
+ <UiAnchor
288
+ anchor="user-connections"
289
+ label="Connections"
290
+ :href="connectionsLink"
291
+ class="settings-row"
292
+ >
293
+ <p class="field-hint">
294
+ Accounts and services you can give your Bots access to. A Bot still
295
+ needs an explicit Assignment before it can use one.
296
+ </p>
297
+
298
+ <section
299
+ v-if="pendingAuthorizations.length"
300
+ class="connect-cards"
301
+ aria-label="Connections that need your authorization"
302
+ >
303
+ <article
304
+ v-for="connection in pendingAuthorizations"
305
+ :key="connection.connectionId"
306
+ class="connect-card"
307
+ >
308
+ <div class="connect-card-copy">
309
+ <strong>{{ connection.pendingAuthorization?.label }}</strong>
310
+ <span>
311
+ Needs your authorization. Only you can complete it — FrockBot
312
+ creates the sign-in link when you press Reconnect, and nothing is
313
+ stored waiting for it.
314
+ </span>
315
+ </div>
316
+ <UiButton
317
+ variant="primary"
318
+ :disabled="reconnectingConnectionId === connection.connectionId"
319
+ @click="reconnect(connection.connectionId)"
320
+ >
321
+ {{
322
+ reconnectingConnectionId === connection.connectionId
323
+ ? "Opening…"
324
+ : "Reconnect"
325
+ }}
326
+ </UiButton>
327
+ </article>
328
+ </section>
329
+
330
+ <div class="connector-grid">
331
+ <article
332
+ v-for="item in connectors"
333
+ :key="item.packageId"
334
+ class="connector-card"
335
+ >
336
+ <div class="connector-summary">
337
+ <span class="connector-logo" aria-hidden="true">
338
+ {{ item.displayName.slice(0, 1) }}
339
+ </span>
340
+ <span class="connector-copy">
341
+ <strong>{{ item.displayName }}</strong>
342
+ <small>
343
+ {{
344
+ item.connectionTypes
345
+ .map((connection) => connection.displayName)
346
+ .join(", ")
347
+ }}
348
+ </small>
349
+ </span>
350
+ <UiButton
351
+ v-if="
352
+ connectionCount(item.packageId) === 0 ||
353
+ item.connectionTypes[0]?.allowMultiple
354
+ "
355
+ @click="beginConnect(item)"
356
+ >
357
+ {{
358
+ connectionCount(item.packageId) === 0
359
+ ? "Connect"
360
+ : "Add another account"
361
+ }}
362
+ </UiButton>
363
+ </div>
364
+
365
+ <PackageAccounts :item="item" />
366
+
367
+ <div
368
+ v-if="
369
+ item.packageId === MCP_PACKAGE_ID &&
370
+ (mcpServers.length > 0 || mcpRefusals.length > 0)
371
+ "
372
+ class="mcp-status"
373
+ >
374
+ <div
375
+ v-for="server in mcpServers"
376
+ :key="server.serverId"
377
+ class="mcp-server"
378
+ >
379
+ <div class="mcp-server-head">
380
+ <span class="mcp-server-name">{{ server.label }}</span>
381
+ <span :class="['mcp-state', `mcp-state-${server.state}`]">
382
+ {{ mcpStateLabel(server.state) }}
383
+ </span>
384
+ </div>
385
+ <p class="mcp-meta">
386
+ {{ server.toolCount }} tools · epoch {{ server.serverEpoch }} ·
387
+ last handshake {{ handshakeLabel(server.lastHandshakeAt) }}
388
+ </p>
389
+ <p v-if="server.failure" class="connection-failure">
390
+ {{ server.failure.code }}: {{ server.failure.message }}
391
+ </p>
392
+ <p v-if="server.instructions" class="mcp-instructions">
393
+ {{ server.instructions }}
394
+ </p>
395
+ <div class="connector-actions">
396
+ <UiButton
397
+ @click="
398
+ beginInstructions(
399
+ server.serverId,
400
+ server.instructions ?? '',
401
+ )
402
+ "
403
+ >
404
+ Instructions
405
+ </UiButton>
406
+ <UiButton
407
+ :disabled="restartingServerId === server.serverId"
408
+ @click="restartServer(server.serverId)"
409
+ >
410
+ {{
411
+ restartingServerId === server.serverId
412
+ ? "Restarting…"
413
+ : "Restart"
414
+ }}
415
+ </UiButton>
416
+ </div>
417
+ <form
418
+ v-if="editingInstructionsFor === server.serverId"
419
+ class="api-key-form"
420
+ @submit.prevent="saveInstructions(server.serverId)"
421
+ >
422
+ <label>
423
+ <span>Instructions for this server's tools</span>
424
+ <textarea
425
+ v-model="instructionsDraft"
426
+ maxlength="4096"
427
+ rows="4"
428
+ ></textarea>
429
+ </label>
430
+ <div class="api-key-actions">
431
+ <UiButton @click="cancelInstructions">Cancel</UiButton>
432
+ <UiButton type="submit" variant="primary">Save</UiButton>
433
+ </div>
434
+ </form>
435
+ </div>
436
+ <p
437
+ v-for="refusal in mcpRefusals"
438
+ :key="refusal.refusalId"
439
+ class="connection-failure"
440
+ >
441
+ {{ refusal.code }}: {{ refusal.message }}
442
+ </p>
443
+ </div>
444
+
445
+ <form
446
+ v-if="mcpFormOpen && item.packageId === MCP_PACKAGE_ID"
447
+ class="api-key-form"
448
+ @submit.prevent="addMcpServer"
449
+ >
450
+ <label>
451
+ <span>Server name</span>
452
+ <input v-model="mcpLabel" maxlength="120" required />
453
+ </label>
454
+ <label>
455
+ <span>Server URL</span>
456
+ <input
457
+ v-model="mcpUrl"
458
+ type="url"
459
+ inputmode="url"
460
+ placeholder="https://example.com/mcp"
461
+ maxlength="2048"
462
+ required
463
+ />
464
+ </label>
465
+ <label>
466
+ <span>Transport</span>
467
+ <select v-model="mcpTransport">
468
+ <option value="streamable-http">Streamable HTTP</option>
469
+ <option value="sse">Server-sent events</option>
470
+ </select>
471
+ </label>
472
+ <label>
473
+ <span>Authentication</span>
474
+ <select v-model="mcpAuthMode">
475
+ <option value="none">None (public server)</option>
476
+ <option value="key">API key</option>
477
+ <option value="oauth">OAuth</option>
478
+ </select>
479
+ </label>
480
+ <label v-if="mcpAuthMode === 'key'">
481
+ <span>API key</span>
482
+ <input
483
+ v-model="mcpApiKey"
484
+ type="password"
485
+ autocomplete="new-password"
486
+ />
487
+ </label>
488
+ <template v-if="mcpAuthMode === 'oauth'">
489
+ <label>
490
+ <span>Scope (optional)</span>
491
+ <input v-model="mcpScope" maxlength="1024" autocomplete="off" />
492
+ </label>
493
+ <label>
494
+ <span>Client ID (optional)</span>
495
+ <input
496
+ v-model="mcpClientId"
497
+ maxlength="512"
498
+ autocomplete="off"
499
+ />
500
+ </label>
501
+ <p class="field-hint">
502
+ Leave both empty for a server that registers clients itself,
503
+ which is what most do. FrockBot registers as a public client; a
504
+ server that issues a client secret is refused, because a secret
505
+ would have to live in this Connection's settings. You will be
506
+ sent to the server to sign in, and no token ever reaches this
507
+ page.
508
+ </p>
509
+ </template>
510
+ <div class="api-key-actions">
511
+ <UiButton @click="cancelMcpConnection">Cancel</UiButton>
512
+ <UiButton type="submit" variant="primary">
513
+ {{
514
+ mcpAuthMode === "oauth" ? "Continue to sign in" : "Add server"
515
+ }}
516
+ </UiButton>
517
+ </div>
518
+ </form>
519
+
520
+ <form
521
+ v-if="apiKeyPackageId === item.packageId"
522
+ class="api-key-form"
523
+ @submit.prevent="connectApiKey"
524
+ >
525
+ <label>
526
+ <span>Connection label</span>
527
+ <input v-model="apiKeyLabel" maxlength="120" required />
528
+ </label>
529
+ <label>
530
+ <span>API key</span>
531
+ <input
532
+ v-model="apiKey"
533
+ type="password"
534
+ autocomplete="new-password"
535
+ required
536
+ />
537
+ </label>
538
+ <div class="api-key-actions">
539
+ <UiButton @click="cancelApiKeyConnection">Cancel</UiButton>
540
+ <UiButton type="submit" variant="primary">
541
+ Connect account
542
+ </UiButton>
543
+ </div>
544
+ </form>
545
+
546
+ <PackageSettingsForm :item="item" />
547
+ </article>
548
+ </div>
549
+
550
+ <div v-if="connectors.length === 0" class="connections-empty">
551
+ <p>
552
+ No connectors are enabled yet. Enable one in Plugins — Composio,
553
+ Gmail, or a remote MCP server — and it will appear here to connect.
554
+ </p>
555
+ <UiButton type="button" @click="surfaces.open('plugins')">
556
+ Open Plugins
557
+ </UiButton>
558
+ </div>
559
+ </UiAnchor>
560
+
561
+ <p v-if="web.settingsError" class="settings-error" role="alert">
562
+ {{ web.settingsError }}
563
+ </p>
564
+ </div>
565
+ </template>
566
+
567
+ <style scoped>
568
+ .connections-surface {
569
+ display: flex;
570
+ flex-direction: column;
571
+ gap: 16px;
572
+ padding: 16px;
573
+ }
574
+
575
+ .settings-row {
576
+ display: flex;
577
+ flex-direction: column;
578
+ gap: 16px;
579
+ padding-right: var(--frock-control-sm);
580
+ }
581
+
582
+ .field-hint {
583
+ margin: 0;
584
+ color: var(--frock-text-muted);
585
+ font-size: var(--frock-text-sm);
586
+ }
587
+
588
+ .connect-cards {
589
+ display: flex;
590
+ flex-direction: column;
591
+ gap: 0.5rem;
592
+ }
593
+
594
+ .connect-card {
595
+ display: flex;
596
+ align-items: center;
597
+ justify-content: space-between;
598
+ gap: 1rem;
599
+ padding: 0.75rem 1rem;
600
+ border: 1px solid var(--frock-border);
601
+ border-radius: var(--frock-radius-card);
602
+ background: var(--frock-surface-raised);
603
+ }
604
+
605
+ .connect-card-copy {
606
+ display: flex;
607
+ flex-direction: column;
608
+ gap: 0.25rem;
609
+ color: var(--frock-text);
610
+ font-size: var(--frock-text-sm);
611
+ line-height: var(--frock-leading-normal);
612
+ }
613
+
614
+ .connect-card-copy span {
615
+ color: var(--frock-text-muted);
616
+ }
617
+
618
+ .connector-grid {
619
+ display: grid;
620
+ gap: 12px;
621
+ }
622
+
623
+ .connector-card {
624
+ min-width: 0;
625
+ padding: 8px;
626
+ border: 1px solid var(--frock-border);
627
+ border-radius: var(--frock-radius-card);
628
+ background: var(--frock-surface-raised);
629
+ box-shadow: var(--frock-shadow-card);
630
+ }
631
+
632
+ .connector-summary {
633
+ display: grid;
634
+ width: 100%;
635
+ min-width: 0;
636
+ grid-template-columns: 44px minmax(0, 1fr) auto;
637
+ align-items: center;
638
+ gap: 12px;
639
+ padding: 8px;
640
+ }
641
+
642
+ .connector-logo {
643
+ display: grid;
644
+ width: 44px;
645
+ height: 44px;
646
+ place-items: center;
647
+ border-radius: 12px;
648
+ color: var(--frock-action-secondary-text);
649
+ background: var(--frock-surface-accent);
650
+ font-weight: 800;
651
+ }
652
+
653
+ .connector-copy {
654
+ min-width: 0;
655
+ }
656
+
657
+ .connector-copy strong,
658
+ .connector-copy small {
659
+ display: block;
660
+ overflow: hidden;
661
+ white-space: nowrap;
662
+ text-overflow: ellipsis;
663
+ }
664
+
665
+ .connector-copy strong {
666
+ font-size: var(--frock-text-md);
667
+ font-weight: 600;
668
+ }
669
+
670
+ .connector-copy small {
671
+ margin-top: 4px;
672
+ color: var(--frock-text-muted);
673
+ font-size: var(--frock-text-sm);
674
+ }
675
+
676
+ .connector-actions {
677
+ display: flex;
678
+ flex-wrap: wrap;
679
+ gap: 8px;
680
+ }
681
+
682
+ .connector-actions :deep(.ui-button) {
683
+ min-height: 28px;
684
+ padding: 0 10px;
685
+ font-size: var(--frock-text-sm);
686
+ }
687
+
688
+ .connections-empty {
689
+ display: flex;
690
+ flex-direction: column;
691
+ align-items: flex-start;
692
+ gap: 10px;
693
+ padding: 12px;
694
+ border: 1px solid var(--frock-border);
695
+ border-radius: var(--frock-radius-card);
696
+ background: var(--frock-surface-subtle);
697
+ }
698
+
699
+ .connections-empty p {
700
+ margin: 0;
701
+ color: var(--frock-text-muted);
702
+ font-size: var(--frock-text-sm);
703
+ }
704
+
705
+ .mcp-status {
706
+ display: grid;
707
+ gap: 12px;
708
+ margin: 0 8px;
709
+ padding: 12px 0 4px;
710
+ border-top: 1px solid var(--frock-border);
711
+ }
712
+
713
+ .mcp-server {
714
+ display: grid;
715
+ gap: 6px;
716
+ }
717
+
718
+ .mcp-server-head {
719
+ display: flex;
720
+ align-items: center;
721
+ gap: 8px;
722
+ }
723
+
724
+ .mcp-server-name {
725
+ color: var(--frock-text);
726
+ font-size: var(--frock-text-base);
727
+ }
728
+
729
+ .mcp-state {
730
+ padding: 1px 8px;
731
+ border: 1px solid var(--frock-border);
732
+ border-radius: 999px;
733
+ color: var(--frock-text-muted);
734
+ font-size: var(--frock-text-sm);
735
+ }
736
+
737
+ .mcp-state-ready {
738
+ color: var(--frock-text);
739
+ }
740
+
741
+ .mcp-state-error,
742
+ .mcp-state-needs-auth {
743
+ border-color: var(--frock-danger-text);
744
+ color: var(--frock-danger-text);
745
+ }
746
+
747
+ .mcp-meta,
748
+ .mcp-instructions {
749
+ margin: 0;
750
+ color: var(--frock-text-muted);
751
+ font-size: var(--frock-text-sm);
752
+ }
753
+
754
+ .api-key-form {
755
+ display: grid;
756
+ gap: 12px;
757
+ margin: 0 8px;
758
+ padding: 12px 0 8px;
759
+ border-top: 1px solid var(--frock-border);
760
+ animation: frock-rise-in var(--frock-motion-enter) both;
761
+ }
762
+
763
+ .api-key-form label {
764
+ display: grid;
765
+ gap: 6px;
766
+ }
767
+
768
+ .api-key-form span {
769
+ color: var(--frock-text-muted);
770
+ font-size: var(--frock-text-sm);
771
+ }
772
+
773
+ .api-key-form input,
774
+ .api-key-form select {
775
+ min-width: 0;
776
+ padding: 8px 11px;
777
+ border: 1px solid var(--frock-border);
778
+ border-radius: 9px;
779
+ background: var(--frock-surface-raised);
780
+ color: var(--frock-text);
781
+ font-size: var(--frock-text-base);
782
+ }
783
+
784
+ .api-key-form textarea {
785
+ min-width: 0;
786
+ padding: 8px 11px;
787
+ border: 1px solid var(--frock-border);
788
+ border-radius: 9px;
789
+ background: var(--frock-surface-raised);
790
+ color: var(--frock-text);
791
+ font-family: inherit;
792
+ font-size: var(--frock-text-base);
793
+ resize: vertical;
794
+ }
795
+
796
+ .api-key-actions {
797
+ display: flex;
798
+ justify-content: flex-end;
799
+ gap: 8px;
800
+ }
801
+
802
+ .connection-failure {
803
+ margin: 0;
804
+ color: var(--frock-danger-text);
805
+ font-size: var(--frock-text-sm);
806
+ }
807
+
808
+ .settings-error {
809
+ margin: 0;
810
+ color: var(--frock-danger-text);
811
+ font-size: var(--frock-text-sm);
812
+ }
813
+ </style>