@agent-native/toolkit 0.227.0 → 0.228.0-nightly-20261111004142

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 (144) hide show
  1. package/agent-native.eject.json +8 -0
  2. package/dist/app/agent-page/AgentTabFrame.d.ts +2 -1
  3. package/dist/app/agent-page/AgentTabFrame.d.ts.map +1 -1
  4. package/dist/app/agent-page/AgentTabFrame.js +3 -2
  5. package/dist/app/agent-page/AgentTabFrame.js.map +1 -1
  6. package/dist/app/chat/AgentPanel.d.ts +15 -15
  7. package/dist/app/chat/AgentPanel.d.ts.map +1 -1
  8. package/dist/app/chat/AgentPanel.js.map +1 -1
  9. package/dist/app/chat/AgentSidebar.d.ts +7 -1
  10. package/dist/app/chat/AgentSidebar.d.ts.map +1 -1
  11. package/dist/app/chat/AgentSidebar.js +47 -16
  12. package/dist/app/chat/AgentSidebar.js.map +1 -1
  13. package/dist/app/chat/chat/surface-types.d.ts +26 -26
  14. package/dist/app/chat/chat/surface-types.d.ts.map +1 -1
  15. package/dist/app/extensions/EmbeddedExtension.d.ts.map +1 -1
  16. package/dist/app/extensions/EmbeddedExtension.js +6 -4
  17. package/dist/app/extensions/EmbeddedExtension.js.map +1 -1
  18. package/dist/app/extensions/ExtensionViewer.d.ts.map +1 -1
  19. package/dist/app/extensions/ExtensionViewer.js +6 -3
  20. package/dist/app/extensions/ExtensionViewer.js.map +1 -1
  21. package/dist/app/extensions/InlineExtensionFrame.d.ts.map +1 -1
  22. package/dist/app/extensions/InlineExtensionFrame.js +32 -28
  23. package/dist/app/extensions/InlineExtensionFrame.js.map +1 -1
  24. package/dist/app/extensions/use-extension-chat-submission.d.ts +14 -0
  25. package/dist/app/extensions/use-extension-chat-submission.d.ts.map +1 -0
  26. package/dist/app/extensions/use-extension-chat-submission.js +36 -0
  27. package/dist/app/extensions/use-extension-chat-submission.js.map +1 -0
  28. package/dist/app/i18n/catalogs/ar-SA.d.ts.map +1 -1
  29. package/dist/app/i18n/catalogs/ar-SA.js +16 -0
  30. package/dist/app/i18n/catalogs/ar-SA.js.map +1 -1
  31. package/dist/app/i18n/catalogs/de-DE.d.ts.map +1 -1
  32. package/dist/app/i18n/catalogs/de-DE.js +12 -0
  33. package/dist/app/i18n/catalogs/de-DE.js.map +1 -1
  34. package/dist/app/i18n/catalogs/en-US.d.ts +12 -0
  35. package/dist/app/i18n/catalogs/en-US.d.ts.map +1 -1
  36. package/dist/app/i18n/catalogs/en-US.js +12 -0
  37. package/dist/app/i18n/catalogs/en-US.js.map +1 -1
  38. package/dist/app/i18n/catalogs/es-ES.d.ts.map +1 -1
  39. package/dist/app/i18n/catalogs/es-ES.js +13 -0
  40. package/dist/app/i18n/catalogs/es-ES.js.map +1 -1
  41. package/dist/app/i18n/catalogs/fr-FR.d.ts.map +1 -1
  42. package/dist/app/i18n/catalogs/fr-FR.js +13 -0
  43. package/dist/app/i18n/catalogs/fr-FR.js.map +1 -1
  44. package/dist/app/i18n/catalogs/hi-IN.d.ts.map +1 -1
  45. package/dist/app/i18n/catalogs/hi-IN.js +12 -0
  46. package/dist/app/i18n/catalogs/hi-IN.js.map +1 -1
  47. package/dist/app/i18n/catalogs/ja-JP.d.ts.map +1 -1
  48. package/dist/app/i18n/catalogs/ja-JP.js +12 -0
  49. package/dist/app/i18n/catalogs/ja-JP.js.map +1 -1
  50. package/dist/app/i18n/catalogs/ko-KR.d.ts.map +1 -1
  51. package/dist/app/i18n/catalogs/ko-KR.js +12 -0
  52. package/dist/app/i18n/catalogs/ko-KR.js.map +1 -1
  53. package/dist/app/i18n/catalogs/pt-BR.d.ts.map +1 -1
  54. package/dist/app/i18n/catalogs/pt-BR.js +13 -0
  55. package/dist/app/i18n/catalogs/pt-BR.js.map +1 -1
  56. package/dist/app/i18n/catalogs/zh-CN.d.ts.map +1 -1
  57. package/dist/app/i18n/catalogs/zh-CN.js +12 -0
  58. package/dist/app/i18n/catalogs/zh-CN.js.map +1 -1
  59. package/dist/app/i18n/catalogs/zh-TW.d.ts.map +1 -1
  60. package/dist/app/i18n/catalogs/zh-TW.js +12 -0
  61. package/dist/app/i18n/catalogs/zh-TW.js.map +1 -1
  62. package/dist/app/org/OrgSwitcher.d.ts.map +1 -1
  63. package/dist/app/org/OrgSwitcher.js +3 -2
  64. package/dist/app/org/OrgSwitcher.js.map +1 -1
  65. package/dist/app/resources/McpAccessSettings.d.ts +9 -2
  66. package/dist/app/resources/McpAccessSettings.d.ts.map +1 -1
  67. package/dist/app/resources/McpAccessSettings.js +264 -86
  68. package/dist/app/resources/McpAccessSettings.js.map +1 -1
  69. package/dist/app/settings/api-keys/ApiKeyDialogs.d.ts +6 -0
  70. package/dist/app/settings/api-keys/ApiKeyDialogs.d.ts.map +1 -1
  71. package/dist/app/settings/api-keys/ApiKeyDialogs.js +74 -2
  72. package/dist/app/settings/api-keys/ApiKeyDialogs.js.map +1 -1
  73. package/dist/app/settings/api-keys/ApiKeysSettingsPage.d.ts.map +1 -1
  74. package/dist/app/settings/api-keys/ApiKeysSettingsPage.js +23 -9
  75. package/dist/app/settings/api-keys/ApiKeysSettingsPage.js.map +1 -1
  76. package/dist/app/settings/api-keys/api-keys-client.d.ts +10 -1
  77. package/dist/app/settings/api-keys/api-keys-client.d.ts.map +1 -1
  78. package/dist/app/settings/api-keys/api-keys-client.js +19 -1
  79. package/dist/app/settings/api-keys/api-keys-client.js.map +1 -1
  80. package/dist/app/settings/shell/pages/mcp.d.ts +1 -1
  81. package/dist/app/settings/shell/pages/mcp.d.ts.map +1 -1
  82. package/dist/app/settings/shell/pages/mcp.js +26 -20
  83. package/dist/app/settings/shell/pages/mcp.js.map +1 -1
  84. package/dist/app/sharing/ShareButton.d.ts +2 -0
  85. package/dist/app/sharing/ShareButton.d.ts.map +1 -1
  86. package/dist/app/sharing/ShareButton.js +4 -3
  87. package/dist/app/sharing/ShareButton.js.map +1 -1
  88. package/dist/composer/TiptapComposer.js +3 -0
  89. package/dist/composer/TiptapComposer.js.map +1 -1
  90. package/dist/sharing/ShareControls.d.ts +2 -0
  91. package/dist/sharing/ShareControls.d.ts.map +1 -1
  92. package/dist/sharing/ShareControls.js +7 -2
  93. package/dist/sharing/ShareControls.js.map +1 -1
  94. package/dist/ui/index.d.ts +1 -0
  95. package/dist/ui/index.d.ts.map +1 -1
  96. package/dist/ui/index.js +1 -0
  97. package/dist/ui/index.js.map +1 -1
  98. package/dist/ui/numbered-steps.d.ts +14 -0
  99. package/dist/ui/numbered-steps.d.ts.map +1 -0
  100. package/dist/ui/numbered-steps.js +19 -0
  101. package/dist/ui/numbered-steps.js.map +1 -0
  102. package/package.json +4 -4
  103. package/src/app/agent-page/AgentTabFrame.tsx +21 -10
  104. package/src/app/chat/AgentPanel.header.spec.ts +4 -1
  105. package/src/app/chat/AgentPanel.tsx +19 -14
  106. package/src/app/chat/AgentSidebar.spec.tsx +167 -0
  107. package/src/app/chat/AgentSidebar.tsx +170 -101
  108. package/src/app/chat/chat/surface-types.ts +35 -31
  109. package/src/app/extensions/EmbeddedExtension.height.spec.tsx +45 -0
  110. package/src/app/extensions/EmbeddedExtension.tsx +6 -3
  111. package/src/app/extensions/ExtensionViewer.spec.tsx +28 -0
  112. package/src/app/extensions/ExtensionViewer.tsx +6 -2
  113. package/src/app/extensions/InlineExtensionFrame.spec.tsx +23 -0
  114. package/src/app/extensions/InlineExtensionFrame.tsx +23 -15
  115. package/src/app/extensions/use-extension-chat-submission.tsx +101 -0
  116. package/src/app/i18n/catalogs/ar-SA.ts +17 -0
  117. package/src/app/i18n/catalogs/de-DE.ts +15 -0
  118. package/src/app/i18n/catalogs/en-US.ts +13 -0
  119. package/src/app/i18n/catalogs/es-ES.ts +16 -0
  120. package/src/app/i18n/catalogs/fr-FR.ts +16 -0
  121. package/src/app/i18n/catalogs/hi-IN.ts +13 -0
  122. package/src/app/i18n/catalogs/ja-JP.ts +15 -0
  123. package/src/app/i18n/catalogs/ko-KR.ts +14 -0
  124. package/src/app/i18n/catalogs/pt-BR.ts +15 -0
  125. package/src/app/i18n/catalogs/zh-CN.ts +13 -0
  126. package/src/app/i18n/catalogs/zh-TW.ts +13 -0
  127. package/src/app/integrations/IntegrationsPanel.spec.tsx +1 -1
  128. package/src/app/org/OrgSwitcher.spec.tsx +43 -0
  129. package/src/app/org/OrgSwitcher.tsx +29 -24
  130. package/src/app/resources/McpAccessSettings.spec.tsx +354 -278
  131. package/src/app/resources/McpAccessSettings.tsx +1012 -323
  132. package/src/app/settings/api-keys/ApiKeyDialogs.tsx +148 -1
  133. package/src/app/settings/api-keys/ApiKeysSettingsPage.spec.tsx +190 -0
  134. package/src/app/settings/api-keys/ApiKeysSettingsPage.tsx +40 -1
  135. package/src/app/settings/api-keys/api-keys-client.spec.ts +49 -0
  136. package/src/app/settings/api-keys/api-keys-client.ts +32 -0
  137. package/src/app/settings/shell/pages/app-group-pages.spec.tsx +31 -21
  138. package/src/app/settings/shell/pages/mcp.tsx +50 -42
  139. package/src/app/sharing/ShareButton.tsx +10 -3
  140. package/src/composer/TiptapComposer.tsx +2 -0
  141. package/src/composer/TiptapComposer.voice-toolbar.spec.tsx +16 -0
  142. package/src/sharing/ShareControls.tsx +44 -24
  143. package/src/ui/index.ts +1 -0
  144. package/src/ui/numbered-steps.tsx +104 -0
@@ -1,415 +1,1104 @@
1
1
  import { appServerPath } from "@agent-native/core/client/api-path";
2
- import { useLocale, useT } from "@agent-native/core/client/i18n";
2
+ import { useLocale, useT, useFormatters } from "@agent-native/core/client/i18n";
3
3
  import { useMcpConnectIdentity } from "@agent-native/core/client/mcp-connect-identity";
4
+ import { mcpIntegrationLogo } from "@agent-native/core/client/resources/mcp-integration-logos";
5
+ import { useActionQuery } from "@agent-native/core/client/use-action";
4
6
  import { docsUrl } from "@agent-native/core/shared/docs-url";
7
+ import type {
8
+ McpClientConnection,
9
+ McpClientConnections,
10
+ } from "@agent-native/core/shared/mcp-client-connections";
5
11
  import {
6
12
  buildMcpInstallLink,
7
13
  getMcpConnectGuides,
8
14
  getMcpStaticTokenFallback,
9
15
  interpolateMcpConnectTemplate,
10
16
  resolveMcpConnectGuideId,
17
+ type McpConnectGuideId,
11
18
  type McpConnectTemplateValues,
12
19
  } from "@agent-native/core/shared/mcp-connect-content";
20
+ import { writeClipboardText } from "@agent-native/toolkit/clipboard";
21
+ import { Alert, AlertDescription } from "@agent-native/toolkit/ui/alert";
22
+ import { Badge } from "@agent-native/toolkit/ui/badge";
13
23
  import { Button } from "@agent-native/toolkit/ui/button";
14
- import { Skeleton } from "@agent-native/toolkit/ui/skeleton";
24
+ import { Card, CardContent } from "@agent-native/toolkit/ui/card";
25
+ import {
26
+ Collapsible,
27
+ CollapsibleContent,
28
+ CollapsibleTrigger,
29
+ } from "@agent-native/toolkit/ui/collapsible";
15
30
  import {
16
- Tabs,
17
- TabsContent,
18
- TabsList,
19
- TabsTrigger,
20
- } from "@agent-native/toolkit/ui/tabs";
31
+ InputGroup,
32
+ InputGroupAddon,
33
+ InputGroupButton,
34
+ InputGroupInput,
35
+ } from "@agent-native/toolkit/ui/input-group";
21
36
  import {
37
+ Item,
38
+ ItemActions,
39
+ ItemContent,
40
+ ItemDescription,
41
+ ItemGroup,
42
+ ItemMedia,
43
+ ItemSeparator,
44
+ ItemTitle,
45
+ } from "@agent-native/toolkit/ui/item";
46
+ import {
47
+ NumberedSteps,
48
+ NumberedStep,
49
+ NumberedStepTitle,
50
+ NumberedStepBody,
51
+ } from "@agent-native/toolkit/ui/numbered-steps";
52
+ import { Separator } from "@agent-native/toolkit/ui/separator";
53
+ import { Skeleton } from "@agent-native/toolkit/ui/skeleton";
54
+ import {
55
+ IconArrowLeft,
22
56
  IconCheck,
57
+ IconChevronDown,
58
+ IconChevronRight,
23
59
  IconCopy,
60
+ IconDeviceDesktop,
24
61
  IconExternalLink,
25
- IconHelpCircle,
62
+ IconPlug,
26
63
  } from "@tabler/icons-react";
27
- import { useEffect, useMemo, useState } from "react";
64
+ import {
65
+ Fragment,
66
+ useEffect,
67
+ useMemo,
68
+ useId,
69
+ useState,
70
+ type MouseEvent,
71
+ type ComponentProps,
72
+ type ReactNode,
73
+ } from "react";
28
74
 
29
75
  import { AgentTabFrame } from "../agent-page/AgentTabFrame.js";
76
+ import {
77
+ BrandLogo,
78
+ BrandMark,
79
+ } from "../integrations/IntegrationDetailParts.js";
80
+ import { SettingsGroup, SettingsRow } from "../settings/SettingsRow.js";
81
+ import { McpIntegrationLogo } from "./McpIntegrationLogo.js";
30
82
 
31
- interface AccessUrls {
32
- appName: string;
33
- appUrl: string;
34
- mcpUrl: string;
35
- serverName: string;
36
- connect: boolean;
37
- connectUrl: string;
38
- agentCardUrl: string;
39
- }
83
+ type McpGuide = ReturnType<typeof getMcpConnectGuides>[number];
40
84
 
41
85
  export const MCP_ACCESS_DOCS_HREF = {
42
86
  mcp: docsUrl("external-agents"),
43
87
  a2a: docsUrl("a2a-protocol"),
44
88
  } as const;
45
89
 
46
- interface CopyFieldProps {
47
- label: string;
48
- value: string;
49
- docsHref?: string;
50
- docsLabel?: string;
90
+ const HOST_LOGOS: Record<string, string> = {
91
+ claude: "claude",
92
+ "claude-code": "claude",
93
+ chatgpt: "openai",
94
+ codex: "openai",
95
+ cursor: "cursor",
96
+ vscode: "vscode",
97
+ grok: "grok",
98
+ };
99
+
100
+ export function McpHostMark({
101
+ id,
102
+ className = "size-5",
103
+ }: {
104
+ id: string;
105
+ className?: string;
106
+ }) {
107
+ const logoId = HOST_LOGOS[id];
108
+ return (
109
+ <BrandMark
110
+ logoUrl={logoId ? mcpIntegrationLogo(logoId) : ""}
111
+ {...(logoId ? { logoId } : {})}
112
+ icon={<IconPlug aria-hidden="true" />}
113
+ className={
114
+ logoId === "openai"
115
+ ? `${className} mix-blend-multiply dark:invert dark:mix-blend-screen`
116
+ : className
117
+ }
118
+ />
119
+ );
51
120
  }
52
121
 
53
- function CopyField({ label, value, docsHref, docsLabel }: CopyFieldProps) {
54
- const t = useT();
55
- const [copied, setCopied] = useState(false);
56
- const copy = async () => {
57
- try {
58
- await navigator.clipboard.writeText(value);
59
- setCopied(true);
60
- window.setTimeout(() => setCopied(false), 1600);
61
- } catch {
62
- setCopied(false);
63
- }
122
+ function HostLogo({ guide }: { guide: McpGuide }) {
123
+ const logoId = HOST_LOGOS[guide.id];
124
+ return logoId ? (
125
+ <McpIntegrationLogo
126
+ name={guide.label}
127
+ logoUrl={mcpIntegrationLogo(logoId)}
128
+ integrationId={logoId}
129
+ className="size-8 rounded-md"
130
+ imageClassName={
131
+ logoId === "openai"
132
+ ? "size-6 mix-blend-multiply dark:invert dark:mix-blend-screen"
133
+ : "size-[18px]"
134
+ }
135
+ />
136
+ ) : (
137
+ <BrandLogo
138
+ name={guide.label}
139
+ logoUrl=""
140
+ icon={<IconPlug aria-hidden="true" />}
141
+ />
142
+ );
143
+ }
144
+
145
+ function modifiedClick(event: MouseEvent<HTMLAnchorElement>) {
146
+ return (
147
+ event.button !== 0 ||
148
+ event.metaKey ||
149
+ event.ctrlKey ||
150
+ event.shiftKey ||
151
+ event.altKey
152
+ );
153
+ }
154
+
155
+ function useCopy(value: string) {
156
+ const [state, setState] = useState<"idle" | "copied" | "error">("idle");
157
+ useEffect(() => {
158
+ if (state !== "copied") return;
159
+ const timeout = window.setTimeout(() => setState("idle"), 1600);
160
+ return () => window.clearTimeout(timeout);
161
+ }, [state]);
162
+ return {
163
+ state,
164
+ copy: async () => {
165
+ try {
166
+ const copied = await writeClipboardText(value);
167
+ setState(copied ? "copied" : "error");
168
+ } catch {
169
+ setState("error");
170
+ }
171
+ },
64
172
  };
173
+ }
174
+
175
+ function CopyField({
176
+ value,
177
+ label,
178
+ fieldLabel,
179
+ }: {
180
+ value: string;
181
+ label: string;
182
+ fieldLabel: string;
183
+ }) {
184
+ const t = useT();
185
+ const { state, copy } = useCopy(value);
65
186
  return (
66
- <div className="flex min-w-0 items-center gap-2 rounded-md border border-border bg-muted/20 p-2">
67
- <div className="min-w-0 flex-1">
68
- <div className="flex items-center gap-1 text-xs font-medium text-muted-foreground">
69
- {label}
70
- {docsHref && (
71
- <Button asChild variant="ghost" size="icon-xs">
72
- <a
73
- href={docsHref}
74
- target="_blank"
75
- rel="noopener noreferrer"
76
- aria-label={docsLabel}
77
- title={docsLabel}
78
- >
79
- <IconHelpCircle aria-hidden="true" />
80
- </a>
81
- </Button>
82
- )}
83
- </div>
84
- <code className="mt-1 block truncate font-mono text-xs text-foreground">
85
- {value}
86
- </code>
87
- </div>
88
- <Button
89
- type="button"
90
- variant="outline"
91
- size="xs"
92
- onClick={() => void copy()}
187
+ <div className="min-w-0 space-y-1">
188
+ <InputGroup aria-label={fieldLabel}>
189
+ <InputGroupInput
190
+ dir="ltr"
191
+ readOnly
192
+ value={value}
193
+ aria-label={fieldLabel}
194
+ className="min-w-0 font-mono text-xs"
195
+ />
196
+ <InputGroupAddon align="inline-end">
197
+ <InputGroupButton
198
+ size="icon-sm"
199
+ aria-label={state === "copied" ? t("settings.mcpCopied") : label}
200
+ onClick={() => void copy()}
201
+ >
202
+ {state === "copied" ? (
203
+ <IconCheck aria-hidden="true" />
204
+ ) : (
205
+ <IconCopy aria-hidden="true" />
206
+ )}
207
+ </InputGroupButton>
208
+ </InputGroupAddon>
209
+ </InputGroup>
210
+ <span
211
+ role="status"
212
+ className={state === "error" ? "text-xs text-destructive" : "sr-only"}
93
213
  >
94
- {copied ? (
214
+ {state === "error"
215
+ ? t("runtimeConfig.copyFailed")
216
+ : state === "copied"
217
+ ? t("settings.mcpCopied")
218
+ : ""}
219
+ </span>
220
+ </div>
221
+ );
222
+ }
223
+
224
+ function DestinationLink({
225
+ destination,
226
+ children,
227
+ "aria-describedby": describedBy,
228
+ ...props
229
+ }: ComponentProps<"a"> & { destination: "new-tab" | "desktop" }) {
230
+ const t = useT();
231
+ const descriptionId = useId();
232
+ const newTab = destination === "new-tab";
233
+ const Icon = newTab ? IconExternalLink : IconDeviceDesktop;
234
+ return (
235
+ <a
236
+ {...props}
237
+ target={newTab ? "_blank" : undefined}
238
+ rel={newTab ? "noopener noreferrer" : undefined}
239
+ aria-describedby={[describedBy, descriptionId].filter(Boolean).join(" ")}
240
+ >
241
+ {children}
242
+ <Icon aria-hidden="true" />
243
+ <span id={descriptionId} hidden>
244
+ {t(newTab ? "settings.mcpOpensNewTab" : "settings.mcpOpensDesktopApp")}
245
+ </span>
246
+ </a>
247
+ );
248
+ }
249
+
250
+ function WebHostButton({ guide }: { guide: McpGuide }) {
251
+ const t = useT();
252
+ if (guide.action?.kind !== "link" || !guide.action.href) return null;
253
+ return (
254
+ <Button asChild size="sm">
255
+ <DestinationLink href={guide.action.href} destination="new-tab">
256
+ {t("settings.mcpStageOpen", { host: guide.label })}
257
+ </DestinationLink>
258
+ </Button>
259
+ );
260
+ }
261
+
262
+ function CopyValueButton({ value, label }: { value: string; label: string }) {
263
+ const t = useT();
264
+ const { state, copy } = useCopy(value);
265
+ return (
266
+ <div className="flex shrink-0 flex-col items-start gap-1 text-foreground">
267
+ <Button variant="outline" size="sm" onClick={() => void copy()}>
268
+ {state === "copied" ? t("settings.mcpCopied") : label}
269
+ {state === "copied" ? (
95
270
  <IconCheck aria-hidden="true" />
96
271
  ) : (
97
272
  <IconCopy aria-hidden="true" />
98
273
  )}
99
- {copied ? t("settings.mcpCopied") : t("settings.mcpCopy")}
100
274
  </Button>
275
+ <span
276
+ role="status"
277
+ className={state === "error" ? "text-xs text-destructive" : "sr-only"}
278
+ >
279
+ {state === "error"
280
+ ? t("runtimeConfig.copyFailed")
281
+ : state === "copied"
282
+ ? t("settings.mcpCopied")
283
+ : ""}
284
+ </span>
101
285
  </div>
102
286
  );
103
287
  }
104
288
 
105
- type McpConnectGuide = ReturnType<typeof getMcpConnectGuides>[number];
106
-
107
- function McpGuidePanel({
289
+ function ConnectionDetails({
108
290
  guide,
109
- templateValues,
291
+ values,
110
292
  }: {
111
- guide: McpConnectGuide;
112
- templateValues: McpConnectTemplateValues;
293
+ guide: McpGuide;
294
+ values: McpConnectTemplateValues;
113
295
  }) {
114
296
  const t = useT();
115
297
  return (
116
- <>
117
- {guide.install?.length ? (
118
- <div className="flex flex-wrap gap-2">
119
- {guide.install.map((option, index) => {
120
- const link = buildMcpInstallLink(option.client, {
121
- serverName: templateValues.serverId,
122
- mcpUrl: templateValues.mcpUrl,
123
- });
124
- return (
125
- <Button
126
- key={option.client}
127
- asChild
128
- size="sm"
129
- variant={index === 0 ? "default" : "outline"}
298
+ <Card className="border bg-muted shadow-none">
299
+ <CardContent className="px-4 py-0">
300
+ <ItemGroup role="group" aria-label={t("settings.mcpStageDetails")}>
301
+ {guide.nameStep !== undefined ? (
302
+ <>
303
+ <Item
304
+ role="group"
305
+ aria-label={t("settings.mcpNameLabel")}
306
+ className="rounded-none border-0 px-0 py-3.5"
130
307
  >
131
- <a
132
- href={link.href}
133
- {...(link.opensWebPage
134
- ? { target: "_blank", rel: "noopener noreferrer" }
135
- : {})}
136
- >
137
- {option.label}
138
- {link.opensWebPage ? (
139
- <IconExternalLink aria-hidden="true" />
140
- ) : null}
141
- </a>
142
- </Button>
143
- );
144
- })}
145
- </div>
308
+ <ItemContent className="grid gap-1 sm:grid-cols-[7rem_minmax(0,1fr)] sm:items-center sm:gap-4">
309
+ <span className="text-xs text-muted-foreground">
310
+ {t("settings.mcpNameLabel")}
311
+ </span>
312
+ <span className="text-sm font-medium">{values.appName}</span>
313
+ </ItemContent>
314
+ </Item>
315
+ <ItemSeparator />
316
+ </>
317
+ ) : null}
318
+ <Item
319
+ role="group"
320
+ aria-label={t("settings.mcpUrlLabel")}
321
+ className="rounded-none border-0 px-0 py-3"
322
+ >
323
+ <ItemContent className="grid gap-2 sm:grid-cols-[7rem_minmax(0,1fr)_auto] sm:items-center sm:gap-4">
324
+ <span className="text-xs text-muted-foreground">
325
+ {t("settings.mcpServerUrl")}
326
+ </span>
327
+ <code dir="ltr" className="min-w-0 break-all font-mono text-sm">
328
+ {values.mcpUrl}
329
+ </code>
330
+ <div className="justify-self-start sm:justify-self-end">
331
+ <CopyValueButton
332
+ value={values.mcpUrl}
333
+ label={t("settings.mcpCopyUrl")}
334
+ />
335
+ </div>
336
+ </ItemContent>
337
+ </Item>
338
+ {guide.id === "claude" || guide.id === "chatgpt" ? (
339
+ <>
340
+ <ItemSeparator />
341
+ <Item className="rounded-none border-0 px-0 py-3.5">
342
+ <ItemContent className="grid gap-1 sm:grid-cols-[7rem_minmax(0,1fr)] sm:items-center sm:gap-4">
343
+ <span className="text-xs text-muted-foreground">
344
+ {t("settings.mcpAuthentication")}
345
+ </span>
346
+ <span className="text-sm font-medium">
347
+ {t("mcpIntegrations.auth.oauth")}
348
+ </span>
349
+ </ItemContent>
350
+ </Item>
351
+ </>
352
+ ) : null}
353
+ </ItemGroup>
354
+ </CardContent>
355
+ </Card>
356
+ );
357
+ }
358
+
359
+ function SetupStep({
360
+ number,
361
+ title,
362
+ last,
363
+ children,
364
+ }: {
365
+ number: number;
366
+ title: string;
367
+ last: boolean;
368
+ children: ReactNode;
369
+ }) {
370
+ return (
371
+ <NumberedStep
372
+ number={number}
373
+ last={last}
374
+ showConnector={false}
375
+ variant="secondary"
376
+ {...(!last ? { contentClassName: "pb-6" } : {})}
377
+ >
378
+ <NumberedStepTitle asChild className="mt-0 leading-[26px]">
379
+ <h2>{title}</h2>
380
+ </NumberedStepTitle>
381
+ <NumberedStepBody className="space-y-2 leading-5">
382
+ {children}
383
+ </NumberedStepBody>
384
+ </NumberedStep>
385
+ );
386
+ }
387
+
388
+ function GuideSteps({
389
+ guide,
390
+ values,
391
+ }: {
392
+ guide: McpGuide;
393
+ values: McpConnectTemplateValues;
394
+ }) {
395
+ const t = useT();
396
+ const titles = {
397
+ open: t(
398
+ guide.install
399
+ ? "settings.mcpStageAddServer"
400
+ : "settings.mcpStageOpenSettings",
401
+ ),
402
+ add: t("settings.mcpStageDetails"),
403
+ configure: t("settings.mcpStageDetails"),
404
+ authorize: t("settings.mcpStageAuthorize", { host: guide.label }),
405
+ enable: t("settings.mcpStageEnable", { host: guide.label }),
406
+ };
407
+ const visibleSteps = guide.steps
408
+ ?.map((text, index) => ({ text, index }))
409
+ .filter(({ index }) => !guide.install || index !== guide.urlStep);
410
+ return (
411
+ <NumberedSteps className="mt-0" aria-label={t("settings.mcpSetupSteps")}>
412
+ {visibleSteps?.map(({ text, index }, position) => {
413
+ const kind = guide.stepKinds?.[index];
414
+ return (
415
+ <SetupStep
416
+ key={text}
417
+ number={position + 1}
418
+ last={position === visibleSteps.length - 1}
419
+ title={kind ? titles[kind] : t("settings.mcpSetupSteps")}
420
+ >
421
+ <div className="flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:gap-4">
422
+ <p className="min-w-0 flex-1 text-sm leading-5 text-muted-foreground">
423
+ {interpolateMcpConnectTemplate(text, values)}
424
+ </p>
425
+ {index === 0 ? (
426
+ <div className="shrink-0">
427
+ <HostConnectAction guide={guide} values={values} />
428
+ </div>
429
+ ) : null}
430
+ </div>
431
+ {guide.urlStep === index ? (
432
+ <>
433
+ <ConnectionDetails guide={guide} values={values} />
434
+ {guide.action?.kind === "link" &&
435
+ /^https?:\/\/(?:localhost|127(?:\.\d+){3}|\[::1\])(?=[:/])/i.test(
436
+ values.mcpUrl,
437
+ ) ? (
438
+ <p className="text-xs leading-5 text-muted-foreground">
439
+ {t("settings.mcpLocalAddressHint", { host: guide.label })}
440
+ </p>
441
+ ) : null}
442
+ </>
443
+ ) : null}
444
+ {index === 0 && guide.install && guide.urlStep !== undefined ? (
445
+ <Collapsible>
446
+ <CollapsibleTrigger asChild>
447
+ <Button
448
+ variant="ghost"
449
+ size="sm"
450
+ className="group -ms-2 text-muted-foreground"
451
+ >
452
+ {t("settings.mcpManualSetup")}
453
+ <IconChevronDown
454
+ className="transition-transform group-data-[state=open]:rotate-180"
455
+ aria-hidden="true"
456
+ />
457
+ </Button>
458
+ </CollapsibleTrigger>
459
+ <CollapsibleContent className="space-y-3 pt-3">
460
+ <p className="text-sm leading-5 text-muted-foreground">
461
+ {interpolateMcpConnectTemplate(
462
+ guide.steps?.[guide.urlStep] ?? "",
463
+ values,
464
+ )}
465
+ </p>
466
+ <ConnectionDetails guide={guide} values={values} />
467
+ {guide.install.slice(1).map((option) => {
468
+ const link = buildMcpInstallLink(option.client, {
469
+ serverName: values.serverId,
470
+ mcpUrl: values.mcpUrl,
471
+ });
472
+ return (
473
+ <Button
474
+ key={option.client}
475
+ asChild
476
+ variant="outline"
477
+ size="sm"
478
+ >
479
+ <DestinationLink
480
+ href={link.href}
481
+ destination={
482
+ link.opensWebPage ? "new-tab" : "desktop"
483
+ }
484
+ >
485
+ {option.label}
486
+ </DestinationLink>
487
+ </Button>
488
+ );
489
+ })}
490
+ </CollapsibleContent>
491
+ </Collapsible>
492
+ ) : null}
493
+ {position === visibleSteps.length - 1 ? (
494
+ <TestPrompt values={values} />
495
+ ) : null}
496
+ </SetupStep>
497
+ );
498
+ })}
499
+ </NumberedSteps>
500
+ );
501
+ }
502
+
503
+ function HostConnectAction({
504
+ guide,
505
+ values,
506
+ }: {
507
+ guide: McpGuide;
508
+ values: McpConnectTemplateValues;
509
+ }) {
510
+ const install = guide.install?.[0];
511
+ if (install) {
512
+ const link = buildMcpInstallLink(install.client, {
513
+ serverName: values.serverId,
514
+ mcpUrl: values.mcpUrl,
515
+ });
516
+ return (
517
+ <Button asChild size="sm">
518
+ <DestinationLink
519
+ href={link.href}
520
+ destination={link.opensWebPage ? "new-tab" : "desktop"}
521
+ >
522
+ {install.label}
523
+ </DestinationLink>
524
+ </Button>
525
+ );
526
+ }
527
+ if (guide.action?.kind === "link") return <WebHostButton guide={guide} />;
528
+ const template = guide.commandTemplate ?? guide.configTemplate;
529
+ return template ? (
530
+ <CopyPrompt
531
+ primary
532
+ {...(guide.action?.label ? { label: guide.action.label } : {})}
533
+ value={interpolateMcpConnectTemplate(template, values)}
534
+ />
535
+ ) : null;
536
+ }
537
+
538
+ function ConnectionBadge({
539
+ status,
540
+ }: {
541
+ status: "connected" | "disconnected" | "unavailable";
542
+ }) {
543
+ const t = useT();
544
+ return (
545
+ <Badge
546
+ variant={status === "unavailable" ? "outline" : "secondary"}
547
+ size="sm"
548
+ className="gap-1"
549
+ >
550
+ {status === "connected" ? (
551
+ <IconCheck className="size-3" aria-hidden="true" />
146
552
  ) : null}
553
+ {t(
554
+ status === "connected"
555
+ ? "settings.mcpConnected"
556
+ : status === "disconnected"
557
+ ? "settings.mcpNotConnected"
558
+ : "settings.mcpConnectionUnavailable",
559
+ )}
560
+ </Badge>
561
+ );
562
+ }
563
+
564
+ function TestPrompt({ values }: { values: McpConnectTemplateValues }) {
565
+ const t = useT();
566
+ return (
567
+ <Item
568
+ variant="muted"
569
+ size="sm"
570
+ className="flex-col items-start border-0 bg-muted text-foreground sm:flex-row sm:items-center"
571
+ >
572
+ <ItemContent>
573
+ <p className="text-sm leading-5 text-foreground">
574
+ {t("settings.mcpTestPrompt", { app: values.appName })}
575
+ </p>
576
+ </ItemContent>
577
+ <CopyValueButton
578
+ value={t("settings.mcpTestPrompt", { app: values.appName })}
579
+ label={t("settings.mcpCopyTestPrompt")}
580
+ />
581
+ </Item>
582
+ );
583
+ }
584
+
585
+ function HostDetail({
586
+ guide,
587
+ values,
588
+ connection,
589
+ statusUnavailable,
590
+ checking,
591
+ }: {
592
+ guide: McpGuide;
593
+ values: McpConnectTemplateValues;
594
+ connection?: McpClientConnection;
595
+ statusUnavailable: boolean;
596
+ checking: boolean;
597
+ }) {
598
+ const t = useT();
599
+ const { formatDate } = useFormatters();
600
+ const connected =
601
+ !checking && !statusUnavailable && connection?.status === "connected";
602
+ const template = guide.commandTemplate ?? guide.configTemplate;
603
+ return (
604
+ <article className="min-w-0 space-y-6">
605
+ <header className="space-y-3">
606
+ <div className="flex flex-wrap items-start justify-between gap-4">
607
+ <p className="min-w-0 flex-1 text-sm leading-5 text-muted-foreground">
608
+ {t(
609
+ guide.id === "other"
610
+ ? "settings.mcpManualBenefit"
611
+ : "settings.mcpHostBenefit",
612
+ { host: guide.label, app: values.appName },
613
+ )}
614
+ </p>
615
+ <div className="shrink-0 space-y-1 text-end">
616
+ {checking ? (
617
+ <Skeleton className="h-4 w-20" />
618
+ ) : (
619
+ <ConnectionBadge
620
+ status={
621
+ statusUnavailable || connection?.status === "unknown"
622
+ ? "unavailable"
623
+ : connected
624
+ ? "connected"
625
+ : "disconnected"
626
+ }
627
+ />
628
+ )}
629
+ {connection?.lastUsedAt != null ? (
630
+ <p className="text-xs text-muted-foreground">
631
+ {t("settings.mcpLastUsed", {
632
+ time: formatDate(connection.lastUsedAt, {
633
+ dateStyle: "medium",
634
+ timeStyle: "short",
635
+ }),
636
+ })}
637
+ </p>
638
+ ) : null}
639
+ </div>
640
+ </div>
641
+ <Separator />
642
+ </header>
147
643
  {guide.steps?.length ? (
148
- <ol className="list-decimal space-y-2 ps-5 text-xs leading-relaxed text-muted-foreground">
149
- {guide.steps.map((step) => (
150
- <li key={step}>
151
- {interpolateMcpConnectTemplate(step, templateValues)}
152
- </li>
153
- ))}
154
- </ol>
644
+ <GuideSteps guide={guide} values={values} />
155
645
  ) : null}
156
- {guide.intro && (
157
- <p className="text-xs text-muted-foreground">
158
- {interpolateMcpConnectTemplate(guide.intro, templateValues)}
159
- </p>
160
- )}
161
- {guide.commandTemplate && (
162
- <CopyField
163
- label={t("settings.mcpCommand")}
164
- value={interpolateMcpConnectTemplate(
165
- guide.commandTemplate,
166
- templateValues,
167
- )}
168
- />
169
- )}
170
- {guide.configTemplate && (
171
- <CopyField
172
- label={t("settings.mcpConfig")}
173
- value={interpolateMcpConnectTemplate(
174
- guide.configTemplate,
175
- templateValues,
646
+ {template ? (
647
+ <NumberedSteps
648
+ className="mt-0"
649
+ aria-label={t("settings.mcpSetupSteps")}
650
+ >
651
+ <SetupStep
652
+ number={1}
653
+ title={t("settings.mcpStageAddServer")}
654
+ last={false}
655
+ >
656
+ <div className="flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:gap-4">
657
+ {guide.intro ? (
658
+ <p className="min-w-0 flex-1 text-sm leading-5 text-muted-foreground">
659
+ {interpolateMcpConnectTemplate(guide.intro, values)}
660
+ </p>
661
+ ) : null}
662
+ <div className="shrink-0">
663
+ <HostConnectAction guide={guide} values={values} />
664
+ </div>
665
+ </div>
666
+ <Card className="border bg-muted shadow-none">
667
+ <CardContent className="p-4">
668
+ <pre
669
+ dir="ltr"
670
+ className="overflow-x-auto whitespace-pre font-mono text-xs leading-5"
671
+ >
672
+ <code>{interpolateMcpConnectTemplate(template, values)}</code>
673
+ </pre>
674
+ </CardContent>
675
+ </Card>
676
+ </SetupStep>
677
+ {guide.commandTemplate && guide.note ? (
678
+ <SetupStep
679
+ number={2}
680
+ title={t("settings.mcpStageAuthorize", { host: guide.label })}
681
+ last
682
+ >
683
+ <p className="text-sm leading-5 text-muted-foreground">
684
+ {interpolateMcpConnectTemplate(guide.note, values)}
685
+ </p>
686
+ <TestPrompt values={values} />
687
+ </SetupStep>
688
+ ) : (
689
+ <SetupStep number={2} title={t("settings.mcpReadyToTry")} last>
690
+ <TestPrompt values={values} />
691
+ </SetupStep>
176
692
  )}
177
- />
178
- )}
179
- {guide.action?.kind === "link" && guide.action.href && (
180
- <Button asChild variant="outline" size="sm">
181
- <a href={guide.action.href} target="_blank" rel="noopener noreferrer">
182
- {guide.action.label}
183
- <IconExternalLink aria-hidden="true" />
184
- </a>
185
- </Button>
186
- )}
187
- {guide.note && (
188
- <p className="text-xs leading-relaxed text-muted-foreground">
189
- {interpolateMcpConnectTemplate(guide.note, templateValues)}
190
- </p>
191
- )}
192
- </>
693
+ </NumberedSteps>
694
+ ) : null}
695
+ <footer className="space-y-3">
696
+ <Separator />
697
+ <div className="flex flex-wrap items-center gap-2 text-xs leading-4 text-muted-foreground">
698
+ <span>{t("settings.mcpHelpConnecting")}</span>
699
+ <Button asChild variant="ghost" size="xs">
700
+ <DestinationLink
701
+ href={guide.docsHref ?? MCP_ACCESS_DOCS_HREF.mcp}
702
+ destination="new-tab"
703
+ >
704
+ {t("settings.mcpConnectionDocs")}
705
+ </DestinationLink>
706
+ </Button>
707
+ </div>
708
+ {guide.id === "other" ? (
709
+ <AdvancedConnection guideId={guide.id} />
710
+ ) : null}
711
+ </footer>
712
+ </article>
713
+ );
714
+ }
715
+
716
+ function CopyPrompt({
717
+ value,
718
+ label,
719
+ primary = false,
720
+ }: {
721
+ value: string;
722
+ label?: string;
723
+ primary?: boolean;
724
+ }) {
725
+ const t = useT();
726
+ const { state, copy } = useCopy(value);
727
+ return (
728
+ <div className="flex flex-col items-start gap-1">
729
+ <Button
730
+ variant={primary ? "default" : "outline"}
731
+ size="sm"
732
+ onClick={() => void copy()}
733
+ >
734
+ {!primary &&
735
+ (state === "copied" ? (
736
+ <IconCheck aria-hidden="true" />
737
+ ) : (
738
+ <IconCopy aria-hidden="true" />
739
+ ))}
740
+ {state === "copied"
741
+ ? t("settings.mcpCopied")
742
+ : (label ?? t("settings.mcpCopy"))}
743
+ </Button>
744
+ <span
745
+ role="status"
746
+ className={
747
+ state === "error" ? "block text-xs text-destructive" : "sr-only"
748
+ }
749
+ >
750
+ {state === "error" ? t("runtimeConfig.copyFailed") : ""}
751
+ </span>
752
+ </div>
193
753
  );
194
754
  }
195
755
 
196
756
  export interface McpAccessSettingsProps {
197
757
  appName?: string;
198
- /** Drop the title and description, for a page whose header already names it. */
199
758
  hideHeader?: boolean;
759
+ helpText?: string;
760
+ guideId?: string | null;
761
+ onSelectGuide?: (id: string) => void;
762
+ guideHref?: (id: string) => string;
200
763
  }
201
764
 
202
765
  export function McpAccessSettings({
203
766
  appName: appNameProp,
204
767
  hideHeader = false,
768
+ helpText,
769
+ guideId,
770
+ onSelectGuide,
771
+ guideHref,
205
772
  }: McpAccessSettingsProps) {
206
773
  const t = useT();
207
774
  const { locale } = useLocale();
208
775
  const guides = useMemo(() => getMcpConnectGuides(locale), [locale]);
209
- const staticTokenFallback = useMemo(
210
- () => getMcpStaticTokenFallback(locale),
211
- [locale],
212
- );
213
776
  const identityState = useMcpConnectIdentity();
214
777
  const identity =
215
778
  identityState.status === "ready" ? identityState.identity : null;
216
- const [urls, setUrls] = useState<AccessUrls | null>(null);
217
- const [agentCardAvailable, setAgentCardAvailable] = useState(false);
218
- const [activeGuide, setActiveGuide] = useState<string>("claude");
219
-
779
+ const connections = useActionQuery<McpClientConnections>(
780
+ "list-mcp-client-connections",
781
+ {},
782
+ {
783
+ enabled: identity?.connect === true,
784
+ staleTime: 0,
785
+ refetchOnWindowFocus: "always",
786
+ retry: false,
787
+ },
788
+ );
789
+ const { formatDate } = useFormatters();
790
+ const [selected, setSelected] = useState<string | null>(null);
220
791
  useEffect(() => {
221
- const syncGuide = () => {
222
- setActiveGuide(
223
- resolveMcpConnectGuideId(
224
- new URLSearchParams(window.location.search).get("guide"),
225
- ),
226
- );
792
+ const sync = () => {
793
+ const query = new URLSearchParams(window.location.search).get("guide");
794
+ setSelected(query ? resolveMcpConnectGuideId(query) : null);
227
795
  };
228
- syncGuide();
229
- window.addEventListener("popstate", syncGuide);
230
- return () => window.removeEventListener("popstate", syncGuide);
796
+ sync();
797
+ window.addEventListener("popstate", sync);
798
+ return () => window.removeEventListener("popstate", sync);
231
799
  }, []);
232
-
233
- useEffect(() => {
234
- if (!identity) {
235
- setUrls(null);
236
- return;
800
+ const activeId = guideId === undefined ? selected : guideId;
801
+ const guide = guides.find((item) => item.id === activeId);
802
+ const appName = appNameProp?.trim() || identity?.appName || "";
803
+ const values = useMemo(
804
+ () =>
805
+ identity
806
+ ? {
807
+ appName,
808
+ appUrl: identity.appUrl,
809
+ mcpUrl: identity.mcpUrl,
810
+ serverId: identity.serverName,
811
+ }
812
+ : null,
813
+ [identity, appName],
814
+ );
815
+ const connection = connections.data?.connections.find(
816
+ (item) => item.client === guide?.id,
817
+ );
818
+ const statusUnavailable =
819
+ connections.isError ||
820
+ (!connections.isPending && Boolean(guide) && !connection);
821
+ const href = (id: string) => {
822
+ if (guideHref) return guideHref(id);
823
+ const query = new URLSearchParams(
824
+ typeof window === "undefined" ? "" : window.location.search,
825
+ );
826
+ query.set("guide", id);
827
+ return `?${query}`;
828
+ };
829
+ const select = (id: string) => {
830
+ if (onSelectGuide) onSelectGuide(id);
831
+ else {
832
+ window.history.pushState(window.history.state, "", href(id));
833
+ setSelected(id);
237
834
  }
238
- const origin = window.location.origin;
239
- const metaSiteName = [
240
- 'meta[name="application-name"]',
241
- 'meta[name="apple-mobile-web-app-title"]',
242
- 'meta[property="og:site_name"]',
243
- ]
244
- .map((selector) =>
245
- document.querySelector(selector)?.getAttribute("content")?.trim(),
246
- )
247
- .find(Boolean);
248
- const appName = appNameProp?.trim() || metaSiteName || identity.appName;
249
- const connectUrl = new URL(appServerPath("/mcp/connect"), origin);
250
- connectUrl.searchParams.set("locale", locale);
251
- connectUrl.searchParams.set("guide", activeGuide);
252
- setUrls({
253
- appName,
254
- appUrl: identity.appUrl,
255
- mcpUrl: identity.mcpUrl,
256
- serverName: identity.serverName,
257
- connect: identity.connect,
258
- connectUrl: connectUrl.toString(),
259
- agentCardUrl: new URL(
260
- appServerPath("/.well-known/agent-card.json"),
261
- origin,
262
- ).toString(),
263
- });
264
- }, [activeGuide, appNameProp, identity, locale]);
265
-
266
- useEffect(() => {
267
- if (!urls) return;
268
- let cancelled = false;
269
- fetch(urls.agentCardUrl)
270
- .then((response) => {
271
- if (!cancelled) setAgentCardAvailable(response.ok);
272
- })
273
- .catch(() => {
274
- if (!cancelled) setAgentCardAvailable(false);
275
- });
276
- return () => {
277
- cancelled = true;
278
- };
279
- }, [urls]);
280
-
281
- const templateValues: McpConnectTemplateValues | null = urls
282
- ? {
283
- appName: urls.appName,
284
- appUrl: urls.appUrl,
285
- mcpUrl: urls.mcpUrl,
286
- serverId: urls.serverName,
287
- }
288
- : null;
289
- const selectGuide = (guideId: string) => {
290
- const url = new URL(window.location.href);
291
- url.searchParams.set("guide", guideId);
292
- window.history.pushState(window.history.state, "", url);
293
- setActiveGuide(guideId);
294
835
  };
295
-
296
836
  return (
297
837
  <AgentTabFrame
298
838
  compact={hideHeader}
299
839
  title={t("settings.mcpTitle")}
300
840
  description={t("settings.mcpDescription")}
301
841
  helpHref={MCP_ACCESS_DOCS_HREF.mcp}
302
- helpLabel={t("settings.mcpOpenDocs")}
842
+ helpLabel={`${t("settings.mcpOpenDocs")} (${t("settings.mcpOpensNewTab")})`}
843
+ helpIcon={<IconExternalLink className="size-4" aria-hidden="true" />}
303
844
  >
304
- <div className="space-y-6">
305
- {urls ? (
306
- <>
307
- <section className="space-y-2">
308
- <CopyField
309
- label={t("settings.mcpUrlLabel")}
310
- value={urls.mcpUrl}
311
- docsHref={MCP_ACCESS_DOCS_HREF.mcp}
312
- docsLabel={t("settings.mcpOpenDocs")}
845
+ {identity && values ? (
846
+ <div className="flex min-w-0 flex-col gap-8">
847
+ {connections.isError ? (
848
+ <Alert>
849
+ <AlertDescription className="flex flex-wrap items-center justify-between gap-3">
850
+ <span>{t("settings.mcpStatusCheckFailed")}</span>
851
+ <Button
852
+ variant="outline"
853
+ size="sm"
854
+ onClick={() => void connections.refetch()}
855
+ >
856
+ {t("settings.mcpRetry")}
857
+ </Button>
858
+ </AlertDescription>
859
+ </Alert>
860
+ ) : null}
861
+ {!connections.isError && connection?.status === "unknown" ? (
862
+ <p className="text-sm leading-6 text-muted-foreground">
863
+ {t("settings.mcpLegacyStatusUnknown")}
864
+ </p>
865
+ ) : null}
866
+
867
+ {guide && identity.connect ? (
868
+ <>
869
+ {!onSelectGuide ? (
870
+ <Button
871
+ asChild
872
+ variant="ghost"
873
+ size="sm"
874
+ className="self-start"
875
+ >
876
+ <a
877
+ href="?"
878
+ onClick={(event) => {
879
+ if (modifiedClick(event)) return;
880
+ event.preventDefault();
881
+ window.history.pushState(
882
+ window.history.state,
883
+ "",
884
+ window.location.pathname,
885
+ );
886
+ setSelected(null);
887
+ }}
888
+ >
889
+ <IconArrowLeft aria-hidden="true" />
890
+ {t("settings.mcpChooseAssistant")}
891
+ </a>
892
+ </Button>
893
+ ) : null}
894
+ <HostDetail
895
+ key={guide.id}
896
+ guide={guide}
897
+ values={values}
898
+ {...(!connections.isError && connection ? { connection } : {})}
899
+ statusUnavailable={statusUnavailable}
900
+ checking={connections.isPending}
313
901
  />
314
- <p className="text-xs leading-relaxed text-muted-foreground">
315
- {t("settings.mcpUrlHint")}
902
+ </>
903
+ ) : identity.connect ? (
904
+ <>
905
+ <p className="text-sm leading-6 text-muted-foreground">
906
+ {helpText ?? t("settings.mcpSetupIntro", { app: appName })}
316
907
  </p>
317
- </section>
318
- {agentCardAvailable && (
319
- <CopyField
320
- label={t("settings.a2aAgentCard")}
321
- value={urls.agentCardUrl}
322
- docsHref={MCP_ACCESS_DOCS_HREF.a2a}
323
- docsLabel={t("settings.a2aOpenDocs")}
324
- />
325
- )}
326
- {/* The guides sign in through, and the token comes from, connect
327
- routes that are not mounted when `mcp.connect` is off. */}
328
- {urls.connect ? (
329
- <>
330
- <section className="space-y-3 border-t border-border/70 pt-6">
331
- <div>
332
- <h3 className="text-sm font-semibold text-foreground">
333
- {t("settings.mcpClientSetup")}
334
- </h3>
335
- <p className="mt-1 text-xs text-muted-foreground">
336
- {t("settings.mcpClientSetupDescription")}
337
- </p>
338
- </div>
339
- <Tabs value={activeGuide} onValueChange={selectGuide}>
340
- <TabsList
341
- aria-label={t("settings.mcpChooseAssistant")}
342
- className="max-w-full justify-start overflow-x-auto"
343
- >
344
- {guides.map((item) => (
345
- <TabsTrigger
346
- key={item.id}
347
- value={item.id}
348
- id={`mcp-guide-tab-${item.id}`}
349
- aria-controls={`mcp-guide-panel-${item.id}`}
350
- >
351
- {item.label}
352
- </TabsTrigger>
353
- ))}
354
- </TabsList>
355
- {templateValues
356
- ? guides.map((item) => (
357
- <TabsContent
358
- key={item.id}
359
- value={item.id}
360
- id={`mcp-guide-panel-${item.id}`}
361
- aria-labelledby={`mcp-guide-tab-${item.id}`}
362
- className="mt-4 space-y-3"
908
+ <SettingsGroup>
909
+ <ItemGroup>
910
+ {guides.map((item, index) => {
911
+ const clientConnection = connections.data?.connections.find(
912
+ (row) => row.client === item.id,
913
+ );
914
+ const lastUsedAt = clientConnection?.lastUsedAt;
915
+ return (
916
+ <Fragment key={item.id}>
917
+ <div role="listitem">
918
+ <Item
919
+ asChild
920
+ className="rounded-none px-5 py-4 sm:px-6"
363
921
  >
364
- <McpGuidePanel
365
- guide={item}
366
- templateValues={templateValues}
367
- />
368
- </TabsContent>
369
- ))
370
- : null}
371
- </Tabs>
372
- </section>
373
- <section className="border-t border-border/70 pt-6">
374
- <h3 className="text-sm font-semibold text-foreground">
375
- {staticTokenFallback.title}
376
- </h3>
377
- <p className="mt-1 text-xs leading-relaxed text-muted-foreground">
378
- {staticTokenFallback.state}.{" "}
379
- {t("settings.mcpStaticTokenDescription")}
380
- </p>
381
- <Button asChild variant="outline" size="sm" className="mt-3">
382
- <a href={urls.connectUrl} target="_blank" rel="noopener">
383
- {t("settings.mcpOpenConnectPage")}
384
- <IconExternalLink aria-hidden="true" />
385
- </a>
386
- </Button>
387
- </section>
388
- </>
389
- ) : null}
390
- </>
391
- ) : identityState.status === "error" ? (
392
- <div className="space-y-3" role="alert">
393
- <p className="text-xs text-muted-foreground">
922
+ <a
923
+ href={href(item.id)}
924
+ onClick={(event) => {
925
+ if (modifiedClick(event)) return;
926
+ event.preventDefault();
927
+ select(item.id);
928
+ }}
929
+ >
930
+ <ItemMedia>
931
+ <HostLogo guide={item} />
932
+ </ItemMedia>
933
+ <ItemContent>
934
+ <ItemTitle>{item.label}</ItemTitle>
935
+ {!connections.isError && lastUsedAt != null ? (
936
+ <ItemDescription>
937
+ {t("settings.mcpLastUsed", {
938
+ time: formatDate(lastUsedAt, {
939
+ dateStyle: "medium",
940
+ timeStyle: "short",
941
+ }),
942
+ })}
943
+ </ItemDescription>
944
+ ) : null}
945
+ </ItemContent>
946
+ <ItemActions>
947
+ {connections.isPending ? (
948
+ <Skeleton className="h-4 w-16" />
949
+ ) : connections.isError ||
950
+ !clientConnection ||
951
+ clientConnection.status === "unknown" ? (
952
+ <ConnectionBadge status="unavailable" />
953
+ ) : clientConnection.status === "connected" ? (
954
+ <ConnectionBadge status="connected" />
955
+ ) : null}
956
+ <IconChevronRight
957
+ className="size-4 text-muted-foreground rtl:-scale-x-100"
958
+ aria-hidden="true"
959
+ />
960
+ </ItemActions>
961
+ </a>
962
+ </Item>
963
+ </div>
964
+ {index < guides.length - 1 ? <ItemSeparator /> : null}
965
+ </Fragment>
966
+ );
967
+ })}
968
+ </ItemGroup>
969
+ </SettingsGroup>
970
+ </>
971
+ ) : (
972
+ <SettingsGroup>
973
+ <SettingsRow
974
+ label={t("settings.mcpUrlLabel")}
975
+ control={
976
+ <CopyField
977
+ fieldLabel={t("settings.mcpUrlLabel")}
978
+ value={identity.mcpUrl}
979
+ label={t("settings.mcpCopy")}
980
+ />
981
+ }
982
+ />
983
+ </SettingsGroup>
984
+ )}
985
+ </div>
986
+ ) : identityState.status === "error" ? (
987
+ <Alert>
988
+ <AlertDescription className="space-y-3">
989
+ <p className="text-sm text-muted-foreground">
394
990
  {t("settings.mcpIdentityError")}
395
991
  </p>
396
- <Button
397
- type="button"
398
- variant="outline"
399
- size="sm"
400
- onClick={identityState.retry}
401
- >
992
+ <Button variant="outline" size="sm" onClick={identityState.retry}>
402
993
  {t("settings.mcpRetry")}
403
994
  </Button>
995
+ </AlertDescription>
996
+ </Alert>
997
+ ) : (
998
+ <SettingsGroup>
999
+ <div className="space-y-4 px-5 py-4" aria-busy="true">
1000
+ {guides.map((item) => (
1001
+ <Skeleton key={item.id} className="h-8 w-full" />
1002
+ ))}
404
1003
  </div>
405
- ) : (
406
- <div className="space-y-3" aria-busy="true">
407
- <Skeleton className="h-5 w-36" />
408
- <Skeleton className="h-16 rounded-md" />
409
- <Skeleton className="h-16 rounded-md" />
410
- </div>
411
- )}
412
- </div>
1004
+ </SettingsGroup>
1005
+ )}
413
1006
  </AgentTabFrame>
414
1007
  );
415
1008
  }
1009
+
1010
+ function AdvancedConnection({ guideId }: { guideId: McpConnectGuideId }) {
1011
+ const t = useT();
1012
+ const { locale } = useLocale();
1013
+ const fallback = getMcpStaticTokenFallback(locale);
1014
+ const connectUrl = `${appServerPath("/mcp/connect")}?${new URLSearchParams({ guide: guideId, locale })}`;
1015
+ const [open, setOpen] = useState(false);
1016
+ const [attempt, setAttempt] = useState(0);
1017
+ const [cardState, setCardState] = useState<
1018
+ "idle" | "loading" | "available" | "absent" | "error"
1019
+ >("idle");
1020
+ const cardPath = appServerPath("/.well-known/agent-card.json");
1021
+ useEffect(() => {
1022
+ if (!open) return;
1023
+ let cancelled = false;
1024
+ setCardState("loading");
1025
+ fetch(cardPath).then(
1026
+ (response) => {
1027
+ if (!cancelled)
1028
+ setCardState(
1029
+ response.ok
1030
+ ? "available"
1031
+ : response.status === 404
1032
+ ? "absent"
1033
+ : "error",
1034
+ );
1035
+ },
1036
+ () => {
1037
+ if (!cancelled) setCardState("error");
1038
+ },
1039
+ );
1040
+ return () => {
1041
+ cancelled = true;
1042
+ };
1043
+ }, [open, attempt, cardPath]);
1044
+ return (
1045
+ <Collapsible open={open} onOpenChange={setOpen}>
1046
+ <CollapsibleTrigger asChild>
1047
+ <Button
1048
+ variant="ghost"
1049
+ size="sm"
1050
+ className="group -ms-2 text-muted-foreground"
1051
+ >
1052
+ {t("settings.mcpConnect.advancedOptions")}
1053
+ <IconChevronDown
1054
+ className="transition-transform group-data-[state=open]:rotate-180"
1055
+ aria-hidden="true"
1056
+ />
1057
+ </Button>
1058
+ </CollapsibleTrigger>
1059
+ <CollapsibleContent className="pt-3">
1060
+ <SettingsGroup>
1061
+ {cardState === "available" ? (
1062
+ <SettingsRow
1063
+ label={t("settings.a2aAgentCard")}
1064
+ control={
1065
+ <CopyField
1066
+ fieldLabel={t("settings.a2aAgentCard")}
1067
+ value={new URL(cardPath, window.location.origin).toString()}
1068
+ label={t("settings.mcpCopy")}
1069
+ />
1070
+ }
1071
+ />
1072
+ ) : cardState === "loading" ? (
1073
+ <div className="px-5 py-4">
1074
+ <Skeleton className="h-8 w-full" />
1075
+ </div>
1076
+ ) : cardState === "error" ? (
1077
+ <Alert>
1078
+ <AlertDescription className="flex flex-wrap items-center justify-between gap-3">
1079
+ <span>{t("settings.mcpIdentityError")}</span>
1080
+ <Button
1081
+ variant="outline"
1082
+ size="sm"
1083
+ onClick={() => setAttempt((value) => value + 1)}
1084
+ >
1085
+ {t("settings.mcpRetry")}
1086
+ </Button>
1087
+ </AlertDescription>
1088
+ </Alert>
1089
+ ) : null}
1090
+ <SettingsRow
1091
+ label={fallback.title}
1092
+ control={
1093
+ <Button asChild variant="outline" size="sm">
1094
+ <DestinationLink href={connectUrl} destination="new-tab">
1095
+ {t("settings.mcpOpenConnectPage")}
1096
+ </DestinationLink>
1097
+ </Button>
1098
+ }
1099
+ />
1100
+ </SettingsGroup>
1101
+ </CollapsibleContent>
1102
+ </Collapsible>
1103
+ );
1104
+ }