@assistant-ui/react-mcp 0.1.16 → 0.1.18

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 (61) hide show
  1. package/dist/auth/createOAuthProvider.d.ts +13 -2
  2. package/dist/auth/createOAuthProvider.d.ts.map +1 -1
  3. package/dist/auth/createOAuthProvider.js +109 -31
  4. package/dist/auth/createOAuthProvider.js.map +1 -1
  5. package/dist/auth/types.d.ts +5 -1
  6. package/dist/auth/types.d.ts.map +1 -1
  7. package/dist/primitives/addForm/McpAddFormAuthFields.js +71 -28
  8. package/dist/primitives/addForm/McpAddFormAuthFields.js.map +1 -1
  9. package/dist/primitives/addForm/McpAddFormError.d.ts.map +1 -1
  10. package/dist/primitives/addForm/McpAddFormError.js +30 -10
  11. package/dist/primitives/addForm/McpAddFormError.js.map +1 -1
  12. package/dist/primitives/addForm/McpAddFormNameField.js +31 -18
  13. package/dist/primitives/addForm/McpAddFormNameField.js.map +1 -1
  14. package/dist/primitives/addForm/McpAddFormRoot.d.ts.map +1 -1
  15. package/dist/primitives/addForm/McpAddFormRoot.js +32 -11
  16. package/dist/primitives/addForm/McpAddFormRoot.js.map +1 -1
  17. package/dist/primitives/addForm/McpAddFormUrlField.js +31 -18
  18. package/dist/primitives/addForm/McpAddFormUrlField.js.map +1 -1
  19. package/dist/primitives/addForm/context.d.ts +9 -1
  20. package/dist/primitives/addForm/context.d.ts.map +1 -1
  21. package/dist/primitives/addForm/context.js.map +1 -1
  22. package/dist/resources/McpManagerResource.d.ts.map +1 -1
  23. package/dist/resources/McpManagerResource.js +330 -247
  24. package/dist/resources/McpManagerResource.js.map +1 -1
  25. package/dist/resources/McpServerRemovalFence.d.ts +7 -0
  26. package/dist/resources/McpServerRemovalFence.d.ts.map +1 -0
  27. package/dist/resources/McpServerRemovalFence.js +11 -0
  28. package/dist/resources/McpServerRemovalFence.js.map +1 -0
  29. package/dist/resources/McpServerResource.d.ts.map +1 -1
  30. package/dist/resources/McpServerResource.js +41 -16
  31. package/dist/resources/McpServerResource.js.map +1 -1
  32. package/dist/resources/storage/McpLocalStorage.d.ts.map +1 -1
  33. package/dist/resources/storage/McpLocalStorage.js +16 -1
  34. package/dist/resources/storage/McpLocalStorage.js.map +1 -1
  35. package/dist/resources/storage/types.d.ts +10 -8
  36. package/dist/resources/storage/types.d.ts.map +1 -1
  37. package/dist/utils/serverUrl.d.ts +8 -0
  38. package/dist/utils/serverUrl.d.ts.map +1 -0
  39. package/dist/utils/serverUrl.js +15 -0
  40. package/dist/utils/serverUrl.js.map +1 -0
  41. package/package.json +7 -7
  42. package/src/auth/createOAuthProvider.test.ts +544 -35
  43. package/src/auth/createOAuthProvider.ts +201 -46
  44. package/src/auth/types.ts +5 -1
  45. package/src/primitives/addForm/McpAddFormAccessibility.test.tsx +261 -0
  46. package/src/primitives/addForm/McpAddFormAuthFields.tsx +33 -15
  47. package/src/primitives/addForm/McpAddFormError.tsx +17 -3
  48. package/src/primitives/addForm/McpAddFormNameField.tsx +13 -1
  49. package/src/primitives/addForm/McpAddFormRoot.tsx +51 -8
  50. package/src/primitives/addForm/McpAddFormUrlField.tsx +12 -1
  51. package/src/primitives/addForm/context.tsx +10 -0
  52. package/src/resources/McpManagerResource.test.ts +712 -9
  53. package/src/resources/McpManagerResource.ts +208 -56
  54. package/src/resources/McpServerRemovalFence.ts +21 -0
  55. package/src/resources/McpServerResource.test.ts +252 -17
  56. package/src/resources/McpServerResource.ts +40 -15
  57. package/src/resources/storage/McpLocalStorage.test.ts +28 -0
  58. package/src/resources/storage/McpLocalStorage.ts +23 -1
  59. package/src/resources/storage/types.ts +10 -8
  60. package/src/utils/serverUrl.test.ts +66 -0
  61. package/src/utils/serverUrl.ts +23 -0
@@ -7,6 +7,11 @@ import type {
7
7
  } from "@modelcontextprotocol/client";
8
8
  import type { MCPStorage } from "../resources/storage/types";
9
9
  import type { MCPAuthConfig } from "../mcp-scope";
10
+ import type { MCPPersistedAuthState } from "./types";
11
+ import {
12
+ isAuthStateForServerUrl,
13
+ normalizeMcpServerUrl,
14
+ } from "../utils/serverUrl";
10
15
 
11
16
  const STATE_PREFIX = "aui-mcp:";
12
17
 
@@ -49,6 +54,7 @@ export function decodeServerIdFromState(state: string): string | null {
49
54
 
50
55
  export type CreateOAuthProviderOptions = {
51
56
  serverId: string;
57
+ serverUrl: string;
52
58
  /** Must be `auth.type === "oauth"`. */
53
59
  config: Extract<MCPAuthConfig, { type: "oauth" }>;
54
60
  storage: MCPStorage;
@@ -58,16 +64,64 @@ export type CreateOAuthProviderOptions = {
58
64
  };
59
65
 
60
66
  type OAuthProviderCache = {
67
+ token?: string | undefined;
61
68
  tokens?: OAuthTokens | undefined;
69
+ tokensClientId?: string | undefined;
62
70
  clientInformation?: OAuthClientInformationFull | undefined;
71
+ clientInformationSource?: MCPPersistedAuthState["clientInformationSource"];
63
72
  codeVerifier?: string | undefined;
64
73
  state?: string | undefined;
65
74
  discoveryState?: OAuthDiscoveryState | undefined;
66
75
  };
67
76
 
68
- type OAuthProviderPersistence = {
77
+ type OAuthConfig = Extract<MCPAuthConfig, { type: "oauth" }>;
78
+
79
+ type OAuthCredentialState = {
80
+ tokens?: OAuthTokens | undefined;
81
+ tokensClientId?: string | undefined;
82
+ clientInformation?: OAuthClientInformationFull | undefined;
83
+ clientInformationSource?: "registered" | undefined;
84
+ };
85
+
86
+ const registeredClientId = (
87
+ state: OAuthCredentialState | null | undefined,
88
+ ): string | undefined =>
89
+ state?.clientInformationSource === "registered"
90
+ ? state.clientInformation?.client_id
91
+ : undefined;
92
+
93
+ export const hasUsableOAuthTokens = (
94
+ state: OAuthCredentialState | null | undefined,
95
+ config: OAuthConfig,
96
+ ): boolean => {
97
+ const clientId = config.clientId ?? registeredClientId(state);
98
+ return (
99
+ clientId !== undefined &&
100
+ state?.tokens !== undefined &&
101
+ state.tokensClientId === clientId
102
+ );
103
+ };
104
+
105
+ const hasUsableRegisteredClientInformation = (
106
+ state: OAuthCredentialState | null | undefined,
107
+ config: OAuthConfig,
108
+ ): boolean => {
109
+ const clientId = registeredClientId(state);
110
+ return (
111
+ clientId !== undefined &&
112
+ (config.clientId === undefined || config.clientId === clientId)
113
+ );
114
+ };
115
+
116
+ type OAuthProviderEndpointCache = {
117
+ serverUrl: string;
69
118
  cached: OAuthProviderCache | null;
70
119
  cachePromise: Promise<OAuthProviderCache> | null;
120
+ invalidated: boolean;
121
+ };
122
+
123
+ type OAuthProviderPersistence = {
124
+ endpoint: OAuthProviderEndpointCache | null;
71
125
  queue: Promise<void>;
72
126
  invalidated: boolean;
73
127
  };
@@ -111,7 +165,11 @@ const persistenceByIdentity = new WeakMap<
111
165
  const getPersistence = (
112
166
  storage: MCPStorage,
113
167
  serverId: string,
114
- ): OAuthProviderPersistence => {
168
+ serverUrl: string,
169
+ ): {
170
+ persistence: OAuthProviderPersistence;
171
+ endpoint: OAuthProviderEndpointCache;
172
+ } => {
115
173
  const identity = getStorageIdentity(storage);
116
174
  let byServerId = persistenceByIdentity.get(identity);
117
175
  if (!byServerId) {
@@ -122,14 +180,25 @@ const getPersistence = (
122
180
  let persistence = byServerId.get(serverId);
123
181
  if (!persistence) {
124
182
  persistence = {
125
- cached: null,
126
- cachePromise: null,
183
+ endpoint: null,
127
184
  queue: Promise.resolve(),
128
185
  invalidated: false,
129
186
  };
130
187
  byServerId.set(serverId, persistence);
131
188
  }
132
- return persistence;
189
+
190
+ let endpoint = persistence.endpoint;
191
+ if (endpoint?.serverUrl !== serverUrl) {
192
+ if (endpoint) endpoint.invalidated = true;
193
+ endpoint = {
194
+ serverUrl,
195
+ cached: null,
196
+ cachePromise: null,
197
+ invalidated: false,
198
+ };
199
+ persistence.endpoint = endpoint;
200
+ }
201
+ return { persistence, endpoint };
133
202
  };
134
203
 
135
204
  /**
@@ -155,7 +224,10 @@ export const clearOAuthProviderAuthState = async (
155
224
  byServerId.delete(serverId);
156
225
  if (byServerId.size === 0) persistenceByIdentity.delete(identity);
157
226
 
158
- await Promise.allSettled([persistence.cachePromise, persistence.queue]);
227
+ if (persistence.endpoint) persistence.endpoint.invalidated = true;
228
+ const cachePromise = persistence.endpoint?.cachePromise;
229
+ if (cachePromise) await Promise.allSettled([cachePromise]);
230
+ await persistence.queue;
159
231
  await storage.clearAuthState(serverId);
160
232
  };
161
233
 
@@ -167,15 +239,28 @@ export const clearOAuthProviderAuthState = async (
167
239
  export function createOAuthProvider(
168
240
  opts: CreateOAuthProviderOptions,
169
241
  ): OAuthClientProvider {
170
- const { serverId, config, storage, redirectUri, onAuthorizationUrl } = opts;
171
- const persistence = getPersistence(storage, serverId);
242
+ const {
243
+ serverId,
244
+ serverUrl,
245
+ config,
246
+ storage,
247
+ redirectUri,
248
+ onAuthorizationUrl,
249
+ } = opts;
250
+ const normalizedServerUrl = normalizeMcpServerUrl(serverUrl);
251
+ const { persistence, endpoint } = getPersistence(
252
+ storage,
253
+ serverId,
254
+ normalizedServerUrl,
255
+ );
172
256
  let pendingState: string | undefined;
173
257
 
174
- // The cache is shared with every other provider for this (storage, serverId),
175
- // so a statically configured client stays a read-time overlay owned by this
176
- // provider. Writing it into the cache would leak this provider's registration
177
- // to a replacement built for a different, or absent, clientId.
178
- const staticClientInformation = (():
258
+ // The cache is shared with every other provider for this storage, server id,
259
+ // and server URL, so a statically configured client stays a read-time overlay
260
+ // owned by this provider. Writing it into the cache would leak this provider's
261
+ // registration to a replacement built for a different, or absent, clientId.
262
+ // The SDK's write-backs, its issuer stamp included, replace the overlay.
263
+ const configuredClientInformation = ():
179
264
  | OAuthClientInformationFull
180
265
  | undefined => {
181
266
  if (!config.clientId) return undefined;
@@ -185,50 +270,92 @@ export function createOAuthProvider(
185
270
  };
186
271
  if (config.clientSecret) ci.client_secret = config.clientSecret;
187
272
  return ci;
188
- })();
273
+ };
274
+ let clientInformationOverlay = configuredClientInformation();
275
+
276
+ const activeClientId = (cache: OAuthProviderCache): string | undefined =>
277
+ clientInformationOverlay?.client_id ?? registeredClientId(cache);
189
278
 
190
279
  const loadCache = (): Promise<OAuthProviderCache> => {
191
- if (persistence.cached) return Promise.resolve(persistence.cached);
192
- if (persistence.cachePromise) return persistence.cachePromise;
193
-
194
- persistence.cachePromise = storage.loadAuthState(serverId).then(
195
- (persisted) => {
196
- const initial: OAuthProviderCache = {};
197
- if (persisted?.tokens) initial.tokens = persisted.tokens;
198
- if (persisted?.clientInformation)
199
- initial.clientInformation = persisted.clientInformation;
200
- if (persisted?.codeVerifier)
201
- initial.codeVerifier = persisted.codeVerifier;
202
- if (persisted?.state) initial.state = persisted.state;
203
- if (persisted?.discoveryState)
204
- initial.discoveryState = persisted.discoveryState;
205
- persistence.cached = initial;
206
- return initial;
207
- },
208
- (error) => {
209
- persistence.cachePromise = null;
210
- throw error;
211
- },
212
- );
213
- return persistence.cachePromise;
280
+ if (endpoint.invalidated) return Promise.resolve({});
281
+ if (endpoint.cached) return Promise.resolve(endpoint.cached);
282
+ if (endpoint.cachePromise) return endpoint.cachePromise;
283
+
284
+ endpoint.cachePromise = persistence.queue
285
+ .then(() => storage.loadAuthState(serverId))
286
+ .then(
287
+ async (persisted) => {
288
+ const initial: OAuthProviderCache = {};
289
+ let needsMigration = false;
290
+ if (endpoint.invalidated) return initial;
291
+ if (
292
+ persisted &&
293
+ isAuthStateForServerUrl(persisted, normalizedServerUrl)
294
+ ) {
295
+ if (
296
+ hasUsableRegisteredClientInformation(persisted, config) &&
297
+ persisted.clientInformation
298
+ ) {
299
+ initial.clientInformation = persisted.clientInformation;
300
+ initial.clientInformationSource = "registered";
301
+ } else if (
302
+ persisted?.clientInformation ||
303
+ persisted?.clientInformationSource !== undefined
304
+ ) {
305
+ needsMigration = true;
306
+ }
307
+ if (hasUsableOAuthTokens(persisted, config)) {
308
+ initial.tokens = persisted.tokens;
309
+ initial.tokensClientId = persisted.tokensClientId;
310
+ } else if (
311
+ persisted?.tokens ||
312
+ persisted?.tokensClientId !== undefined
313
+ ) {
314
+ needsMigration = true;
315
+ }
316
+ if (persisted?.token) initial.token = persisted.token;
317
+ if (persisted?.codeVerifier)
318
+ initial.codeVerifier = persisted.codeVerifier;
319
+ if (persisted?.state) initial.state = persisted.state;
320
+ if (persisted?.discoveryState)
321
+ initial.discoveryState = persisted.discoveryState;
322
+ }
323
+ endpoint.cached = initial;
324
+ if (needsMigration) await persist().catch(() => {});
325
+ return initial;
326
+ },
327
+ (error) => {
328
+ endpoint.cachePromise = null;
329
+ throw error;
330
+ },
331
+ );
332
+ return endpoint.cachePromise;
214
333
  };
215
334
 
216
- const persist = () => {
335
+ function persist() {
217
336
  const task = persistence.queue.then(async () => {
218
- if (persistence.invalidated) return;
219
- const c = persistence.cached;
337
+ if (persistence.invalidated || endpoint.invalidated) return;
338
+ const c = endpoint.cached;
220
339
  if (!c) return;
221
340
  const next: Parameters<typeof storage.saveAuthState>[1] = {};
222
- if (c.tokens) next.tokens = c.tokens;
223
- if (c.clientInformation) next.clientInformation = c.clientInformation;
341
+ if (hasUsableOAuthTokens(c, config) && c.tokens && c.tokensClientId) {
342
+ next.tokens = c.tokens;
343
+ next.tokensClientId = c.tokensClientId;
344
+ }
345
+ if (c.clientInformation && c.clientInformationSource === "registered") {
346
+ next.clientInformation = c.clientInformation;
347
+ next.clientInformationSource = "registered";
348
+ }
349
+ if (c.token) next.token = c.token;
224
350
  if (c.codeVerifier) next.codeVerifier = c.codeVerifier;
225
351
  if (c.state) next.state = c.state;
226
352
  if (c.discoveryState) next.discoveryState = c.discoveryState;
353
+ next.serverUrl = normalizedServerUrl;
227
354
  await storage.saveAuthState(serverId, next);
228
355
  });
229
356
  persistence.queue = task.catch(() => {});
230
357
  return task;
231
- };
358
+ }
232
359
 
233
360
  const clientMetadata: OAuthClientMetadata = {
234
361
  client_name: "assistant-ui",
@@ -258,20 +385,37 @@ export function createOAuthProvider(
258
385
  },
259
386
  async clientInformation() {
260
387
  const c = await loadCache();
261
- return staticClientInformation ?? c.clientInformation;
388
+ if (clientInformationOverlay) return clientInformationOverlay;
389
+ if (c.clientInformationSource !== "registered") return undefined;
390
+ return c.clientInformation;
262
391
  },
263
392
  async saveClientInformation(info) {
393
+ if (clientInformationOverlay) {
394
+ clientInformationOverlay = info as OAuthClientInformationFull;
395
+ return;
396
+ }
264
397
  const c = await loadCache();
265
398
  c.clientInformation = info as OAuthClientInformationFull;
399
+ c.clientInformationSource = "registered";
400
+ if (c.tokensClientId !== c.clientInformation.client_id) {
401
+ delete c.tokens;
402
+ delete c.tokensClientId;
403
+ }
266
404
  await persist();
267
405
  },
268
406
  async tokens() {
269
407
  const c = await loadCache();
408
+ const clientId = activeClientId(c);
409
+ if (clientId === undefined || c.tokensClientId !== clientId)
410
+ return undefined;
270
411
  return c.tokens;
271
412
  },
272
413
  async saveTokens(tokens) {
273
414
  const c = await loadCache();
274
415
  c.tokens = tokens;
416
+ const clientId = activeClientId(c);
417
+ if (clientId) c.tokensClientId = clientId;
418
+ else delete c.tokensClientId;
275
419
  delete c.state;
276
420
  await persist();
277
421
  },
@@ -305,8 +449,19 @@ export function createOAuthProvider(
305
449
  },
306
450
  async invalidateCredentials(scope) {
307
451
  const c = await loadCache();
308
- if (scope === "all" || scope === "tokens") delete c.tokens;
309
- if (scope === "all" || scope === "client") delete c.clientInformation;
452
+ if (scope === "all" || scope === "tokens") {
453
+ delete c.tokens;
454
+ delete c.tokensClientId;
455
+ }
456
+ if (scope === "all" || scope === "client") {
457
+ delete c.clientInformation;
458
+ delete c.clientInformationSource;
459
+ if (!config.clientId) {
460
+ delete c.tokens;
461
+ delete c.tokensClientId;
462
+ }
463
+ clientInformationOverlay = configuredClientInformation();
464
+ }
310
465
  if (scope === "all" || scope === "verifier") {
311
466
  delete c.codeVerifier;
312
467
  delete c.state;
package/src/auth/types.ts CHANGED
@@ -5,11 +5,15 @@ import type {
5
5
  } from "@modelcontextprotocol/client";
6
6
 
7
7
  export type MCPPersistedAuthState = {
8
+ /** MCP server URL this authentication state belongs to. Required with credentials. */
9
+ serverUrl?: string;
8
10
  tokens?: OAuthTokens;
11
+ tokensClientId?: string;
9
12
  clientInformation?: OAuthClientInformationFull;
13
+ clientInformationSource?: "registered";
10
14
  codeVerifier?: string;
11
15
  state?: string;
12
16
  discoveryState?: OAuthDiscoveryState;
13
- /** Bearer token (entered at add-form time). */
17
+ /** Host-persisted bearer token. Must be paired with serverUrl. */
14
18
  token?: string;
15
19
  };
@@ -0,0 +1,261 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import {
4
+ cleanup,
5
+ fireEvent,
6
+ render,
7
+ screen,
8
+ waitFor,
9
+ within,
10
+ } from "@testing-library/react";
11
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
12
+ import { createRef } from "react";
13
+
14
+ const mocks = vi.hoisted(() => ({ addCustomServer: vi.fn() }));
15
+ vi.mock("@assistant-ui/store", async (importOriginal) => ({
16
+ ...(await importOriginal()),
17
+ useAui: () => ({ mcp: { addCustomServer: mocks.addCustomServer } }),
18
+ }));
19
+
20
+ import { McpAddFormPrimitiveRoot as Root } from "./McpAddFormRoot";
21
+ import { McpAddFormPrimitiveNameField as NameField } from "./McpAddFormNameField";
22
+ import { McpAddFormPrimitiveUrlField as UrlField } from "./McpAddFormUrlField";
23
+ import { McpAddFormPrimitiveAuthSelect as AuthSelect } from "./McpAddFormAuthSelect";
24
+ import { McpAddFormPrimitiveAuthFields as AuthFields } from "./McpAddFormAuthFields";
25
+ import { McpAddFormPrimitiveError as ErrorMessage } from "./McpAddFormError";
26
+
27
+ const Form = ({ name = "Add server" }: { name?: string }) => (
28
+ <Root aria-label={name}>
29
+ <label>
30
+ Name
31
+ <NameField />
32
+ </label>
33
+ <label>
34
+ URL
35
+ <UrlField />
36
+ </label>
37
+ <label>
38
+ Auth
39
+ <AuthSelect />
40
+ </label>
41
+ <AuthFields />
42
+ <ErrorMessage />
43
+ </Root>
44
+ );
45
+
46
+ beforeEach(() => {
47
+ mocks.addCustomServer.mockReset().mockResolvedValue("server");
48
+ });
49
+ afterEach(cleanup);
50
+
51
+ describe("MCP add form accessibility", () => {
52
+ it("does not add a wrapper around an asChild error without a custom ID", () => {
53
+ render(
54
+ <Root aria-label="Add server">
55
+ <NameField aria-label="Name" />
56
+ <ErrorMessage asChild>
57
+ <div>Enter the server name.</div>
58
+ </ErrorMessage>
59
+ </Root>,
60
+ );
61
+ fireEvent.submit(screen.getByRole("form"));
62
+ const error = screen.getByRole("alert");
63
+ expect(error.parentElement).toBe(screen.getByRole("form"));
64
+ expect(error.id).not.toBe("");
65
+ expect(
66
+ screen
67
+ .getByRole("textbox", { name: "Name" })
68
+ .getAttribute("aria-describedby"),
69
+ ).toBe(error.id);
70
+ });
71
+
72
+ it.each(["element", "slot", "child"] as const)(
73
+ "keeps a custom error ID on the %s associated with fields",
74
+ (target) => {
75
+ const ref = createRef<HTMLDivElement>();
76
+ render(
77
+ <Root aria-label="Add server">
78
+ <NameField aria-label="Name" />
79
+ <ErrorMessage
80
+ {...(target !== "child" && { id: "custom-error" })}
81
+ asChild={target !== "element"}
82
+ ref={ref}
83
+ >
84
+ {target !== "element" ? (
85
+ <div {...(target === "child" && { id: "custom-error" })}>
86
+ Enter the server name.
87
+ </div>
88
+ ) : (
89
+ "Enter the server name."
90
+ )}
91
+ </ErrorMessage>
92
+ </Root>,
93
+ );
94
+ fireEvent.submit(screen.getByRole("form"));
95
+ const error = screen.getByRole("alert");
96
+ expect(error.id).toBe("custom-error");
97
+ expect(ref.current).toBe(error);
98
+ const descriptionId = screen
99
+ .getByRole("textbox", { name: "Name" })
100
+ .getAttribute("aria-describedby")!;
101
+ expect(document.getElementById(descriptionId)?.textContent).toBe(
102
+ "Enter the server name.",
103
+ );
104
+ expect(document.querySelectorAll(`[id="${descriptionId}"]`)).toHaveLength(
105
+ 1,
106
+ );
107
+ },
108
+ );
109
+
110
+ it("keeps enclosing labels as the accessible names", () => {
111
+ render(
112
+ <Root>
113
+ <label>
114
+ Server name
115
+ <NameField />
116
+ </label>
117
+ <label>
118
+ Server address
119
+ <UrlField />
120
+ </label>
121
+ <label>
122
+ Authentication method
123
+ <AuthSelect />
124
+ </label>
125
+ </Root>,
126
+ );
127
+ expect(screen.getByRole("textbox", { name: "Server name" })).toBeTruthy();
128
+ expect(
129
+ screen.getByRole("textbox", { name: "Server address" }),
130
+ ).toBeTruthy();
131
+ expect(
132
+ screen.getByRole("combobox", { name: "Authentication method" }),
133
+ ).toBeTruthy();
134
+ });
135
+ it("preserves field labels and only marks the field with an error", () => {
136
+ render(<Form />);
137
+ const name = screen.getByRole("textbox", { name: "Name" });
138
+ const url = screen.getByRole("textbox", { name: "URL" });
139
+ const auth = screen.getByRole("combobox", { name: "Auth" });
140
+ expect(screen.getByRole("textbox", { name: "OAuth scopes" })).toBeTruthy();
141
+
142
+ fireEvent.submit(screen.getByRole("form"));
143
+ const error = screen.getByRole("alert");
144
+ expect(error.textContent).toBe("Name is required");
145
+ expect(name.getAttribute("aria-invalid")).toBe("true");
146
+ expect(name.getAttribute("aria-describedby")).toBe(error.id);
147
+ expect(url.hasAttribute("aria-invalid")).toBe(false);
148
+
149
+ fireEvent.change(name, { target: { value: "Docs" } });
150
+ expect(screen.queryByRole("alert")).toBeNull();
151
+ expect(name.hasAttribute("aria-invalid")).toBe(false);
152
+ fireEvent.submit(screen.getByRole("form"));
153
+ expect(url.getAttribute("aria-describedby")).toBe(
154
+ screen.getByRole("alert").id,
155
+ );
156
+ fireEvent.change(url, { target: { value: "https://example.com/mcp" } });
157
+ fireEvent.change(auth, { target: { value: "bearer" } });
158
+ fireEvent.submit(screen.getByRole("form"));
159
+ const token = screen.getByLabelText("Bearer token");
160
+ expect(token.getAttribute("aria-invalid")).toBe("true");
161
+ expect(token.getAttribute("aria-describedby")).toBe(
162
+ screen.getByRole("alert").id,
163
+ );
164
+ fireEvent.change(auth, { target: { value: "none" } });
165
+ expect(screen.queryByRole("alert")).toBeNull();
166
+ expect(screen.queryByLabelText("Bearer token")).toBeNull();
167
+ });
168
+
169
+ it("announces server errors without marking unrelated fields invalid", async () => {
170
+ mocks.addCustomServer.mockRejectedValue(new Error("Connection failed"));
171
+ render(<Form />);
172
+ fireEvent.change(screen.getByRole("textbox", { name: "Name" }), {
173
+ target: { value: "Docs" },
174
+ });
175
+ fireEvent.change(screen.getByRole("textbox", { name: "URL" }), {
176
+ target: { value: "https://example.com/mcp" },
177
+ });
178
+ fireEvent.submit(screen.getByRole("form"));
179
+ await waitFor(() =>
180
+ expect(screen.getByRole("alert").textContent).toBe("Connection failed"),
181
+ );
182
+ expect(document.querySelector('[aria-invalid="true"]')).toBeNull();
183
+ });
184
+
185
+ it("uses distinct IDs for simultaneously mounted forms", () => {
186
+ render(
187
+ <>
188
+ <Form name="First" />
189
+ <Form name="Second" />
190
+ </>,
191
+ );
192
+ const first = screen.getByRole("form", { name: "First" });
193
+ const second = screen.getByRole("form", { name: "Second" });
194
+ fireEvent.submit(first);
195
+ fireEvent.submit(second);
196
+ const firstError = within(first).getByRole("alert");
197
+ const secondError = within(second).getByRole("alert");
198
+ expect(firstError.id).not.toBe(secondError.id);
199
+ expect(
200
+ within(first)
201
+ .getByRole("textbox", { name: "Name" })
202
+ .getAttribute("aria-describedby"),
203
+ ).toBe(firstError.id);
204
+ expect(
205
+ within(second)
206
+ .getByRole("textbox", { name: "Name" })
207
+ .getAttribute("aria-describedby"),
208
+ ).toBe(secondError.id);
209
+ });
210
+
211
+ it("preserves caller IDs and ARIA overrides while associating errors", () => {
212
+ render(
213
+ <Root aria-label="Add server">
214
+ <NameField
215
+ id="server-name"
216
+ aria-label="Server name"
217
+ aria-describedby="hint"
218
+ aria-invalid={false}
219
+ />
220
+ <p id="hint">Use a recognizable name.</p>
221
+ <ErrorMessage />
222
+ </Root>,
223
+ );
224
+ const field = screen.getByRole("textbox", { name: "Server name" });
225
+ expect(field.id).toBe("server-name");
226
+ expect(field.getAttribute("aria-describedby")).toBe("hint");
227
+ fireEvent.submit(screen.getByRole("form"));
228
+ expect(field.getAttribute("aria-invalid")).toBe("false");
229
+ expect(field.getAttribute("aria-describedby")).toBe(
230
+ `hint ${screen.getByRole("alert").id}`,
231
+ );
232
+ fireEvent.change(field, { target: { value: "Docs" } });
233
+ expect(screen.queryByRole("alert")).toBeNull();
234
+ expect(field.getAttribute("aria-describedby")).toBe("hint");
235
+ });
236
+
237
+ it("keeps URL help text associated when validation errors appear and clear", () => {
238
+ render(
239
+ <Root aria-label="Add server">
240
+ <NameField aria-label="Name" />
241
+ <UrlField aria-label="URL" aria-describedby="url-hint url-format" />
242
+ <p id="url-hint">Use the server endpoint.</p>
243
+ <p id="url-format">An HTTP or HTTPS URL is required.</p>
244
+ <ErrorMessage />
245
+ </Root>,
246
+ );
247
+ fireEvent.change(screen.getByRole("textbox", { name: "Name" }), {
248
+ target: { value: "Docs" },
249
+ });
250
+ const field = screen.getByRole("textbox", { name: "URL" });
251
+ fireEvent.submit(screen.getByRole("form"));
252
+ expect(field.getAttribute("aria-invalid")).toBe("true");
253
+ expect(field.getAttribute("aria-describedby")).toBe(
254
+ `url-hint url-format ${screen.getByRole("alert").id}`,
255
+ );
256
+ fireEvent.change(field, { target: { value: "https://example.com/mcp" } });
257
+ expect(screen.queryByRole("alert")).toBeNull();
258
+ expect(field.hasAttribute("aria-invalid")).toBe(false);
259
+ expect(field.getAttribute("aria-describedby")).toBe("url-hint url-format");
260
+ });
261
+ });