@assistant-ui/react-mcp 0.1.17 → 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 (45) hide show
  1. package/dist/primitives/addForm/McpAddFormAuthFields.js +71 -28
  2. package/dist/primitives/addForm/McpAddFormAuthFields.js.map +1 -1
  3. package/dist/primitives/addForm/McpAddFormError.d.ts.map +1 -1
  4. package/dist/primitives/addForm/McpAddFormError.js +30 -10
  5. package/dist/primitives/addForm/McpAddFormError.js.map +1 -1
  6. package/dist/primitives/addForm/McpAddFormNameField.js +31 -18
  7. package/dist/primitives/addForm/McpAddFormNameField.js.map +1 -1
  8. package/dist/primitives/addForm/McpAddFormRoot.d.ts.map +1 -1
  9. package/dist/primitives/addForm/McpAddFormRoot.js +32 -11
  10. package/dist/primitives/addForm/McpAddFormRoot.js.map +1 -1
  11. package/dist/primitives/addForm/McpAddFormUrlField.js +31 -18
  12. package/dist/primitives/addForm/McpAddFormUrlField.js.map +1 -1
  13. package/dist/primitives/addForm/context.d.ts +9 -1
  14. package/dist/primitives/addForm/context.d.ts.map +1 -1
  15. package/dist/primitives/addForm/context.js.map +1 -1
  16. package/dist/resources/McpManagerResource.d.ts.map +1 -1
  17. package/dist/resources/McpManagerResource.js +330 -247
  18. package/dist/resources/McpManagerResource.js.map +1 -1
  19. package/dist/resources/McpServerRemovalFence.d.ts +7 -0
  20. package/dist/resources/McpServerRemovalFence.d.ts.map +1 -0
  21. package/dist/resources/McpServerRemovalFence.js +11 -0
  22. package/dist/resources/McpServerRemovalFence.js.map +1 -0
  23. package/dist/resources/McpServerResource.d.ts.map +1 -1
  24. package/dist/resources/McpServerResource.js +5 -1
  25. package/dist/resources/McpServerResource.js.map +1 -1
  26. package/dist/resources/storage/McpLocalStorage.d.ts.map +1 -1
  27. package/dist/resources/storage/McpLocalStorage.js +2 -1
  28. package/dist/resources/storage/McpLocalStorage.js.map +1 -1
  29. package/dist/resources/storage/types.d.ts +10 -8
  30. package/dist/resources/storage/types.d.ts.map +1 -1
  31. package/package.json +5 -5
  32. package/src/primitives/addForm/McpAddFormAccessibility.test.tsx +261 -0
  33. package/src/primitives/addForm/McpAddFormAuthFields.tsx +33 -15
  34. package/src/primitives/addForm/McpAddFormError.tsx +17 -3
  35. package/src/primitives/addForm/McpAddFormNameField.tsx +13 -1
  36. package/src/primitives/addForm/McpAddFormRoot.tsx +51 -8
  37. package/src/primitives/addForm/McpAddFormUrlField.tsx +12 -1
  38. package/src/primitives/addForm/context.tsx +10 -0
  39. package/src/resources/McpManagerResource.test.ts +712 -9
  40. package/src/resources/McpManagerResource.ts +205 -56
  41. package/src/resources/McpServerRemovalFence.ts +21 -0
  42. package/src/resources/McpServerResource.ts +5 -1
  43. package/src/resources/storage/McpLocalStorage.test.ts +2 -0
  44. package/src/resources/storage/McpLocalStorage.ts +2 -1
  45. package/src/resources/storage/types.ts +10 -8
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assistant-ui/react-mcp",
3
- "version": "0.1.17",
3
+ "version": "0.1.18",
4
4
  "description": "MCP server configuration and connection primitives for @assistant-ui",
5
5
  "keywords": [
6
6
  "mcp",
@@ -27,13 +27,13 @@
27
27
  ],
28
28
  "sideEffects": false,
29
29
  "dependencies": {
30
- "@assistant-ui/core": "^0.3.18",
30
+ "@assistant-ui/core": "^0.3.19",
31
31
  "@assistant-ui/store": "^0.3.13",
32
32
  "@assistant-ui/tap": "^0.9.17",
33
33
  "@modelcontextprotocol/client": "^2.0.0",
34
34
  "@modelcontextprotocol/core": "^2.0.0",
35
35
  "@radix-ui/react-primitive": "^2.1.10",
36
- "assistant-stream": "^0.3.42"
36
+ "assistant-stream": "^0.3.43"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@types/react": "*",
@@ -45,13 +45,13 @@
45
45
  }
46
46
  },
47
47
  "devDependencies": {
48
- "@assistant-ui/x-buildutils": "0.0.26",
48
+ "@assistant-ui/x-buildutils": "0.0.27",
49
49
  "@testing-library/dom": "^10.4.1",
50
50
  "@testing-library/react": "^16.3.3",
51
51
  "@types/react": "^19.2.18",
52
52
  "jsdom": "^30.0.1",
53
53
  "react": "^19.2.8",
54
- "vitest": "^4.1.11"
54
+ "vitest": "^5.0.0"
55
55
  },
56
56
  "publishConfig": {
57
57
  "access": "public",
@@ -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
+ });
@@ -14,7 +14,7 @@ export namespace McpAddFormPrimitiveAuthFields {
14
14
  export const McpAddFormPrimitiveAuthFields: FC<
15
15
  McpAddFormPrimitiveAuthFields.Props
16
16
  > = ({ children }) => {
17
- const { state, setField } = useAddForm();
17
+ const { state, ids, setField } = useAddForm();
18
18
 
19
19
  if (children) {
20
20
  const Render = children;
@@ -23,25 +23,43 @@ export const McpAddFormPrimitiveAuthFields: FC<
23
23
 
24
24
  if (state.authType === "bearer") {
25
25
  return (
26
- <input
27
- type="password"
28
- placeholder="Bearer token"
29
- value={state.bearerToken}
30
- onChange={(e) => setField("bearerToken", e.target.value)}
31
- data-mcp-auth-field="bearer-token"
32
- />
26
+ <div>
27
+ <label
28
+ htmlFor={ids.bearerToken}
29
+ data-mcp-auth-field-label="bearer-token"
30
+ >
31
+ Bearer token
32
+ </label>
33
+ <input
34
+ id={ids.bearerToken}
35
+ type="password"
36
+ value={state.bearerToken}
37
+ onChange={(e) => setField("bearerToken", e.target.value)}
38
+ aria-invalid={state.errorField === "bearerToken" ? true : undefined}
39
+ aria-describedby={
40
+ state.errorField === "bearerToken" ? ids.error : undefined
41
+ }
42
+ data-mcp-auth-field="bearer-token"
43
+ />
44
+ </div>
33
45
  );
34
46
  }
35
47
 
36
48
  if (state.authType === "oauth") {
37
49
  return (
38
- <input
39
- type="text"
40
- placeholder="Scopes (space-separated, optional)"
41
- value={state.scopes}
42
- onChange={(e) => setField("scopes", e.target.value)}
43
- data-mcp-auth-field="oauth-scopes"
44
- />
50
+ <div>
51
+ <label htmlFor={ids.scopes} data-mcp-auth-field-label="oauth-scopes">
52
+ OAuth scopes
53
+ </label>
54
+ <input
55
+ id={ids.scopes}
56
+ type="text"
57
+ placeholder="Scopes (space-separated, optional)"
58
+ value={state.scopes}
59
+ onChange={(e) => setField("scopes", e.target.value)}
60
+ data-mcp-auth-field="oauth-scopes"
61
+ />
62
+ </div>
45
63
  );
46
64
  }
47
65
 
@@ -2,6 +2,7 @@ import {
2
2
  type ComponentPropsWithoutRef,
3
3
  type ComponentRef,
4
4
  forwardRef,
5
+ isValidElement,
5
6
  } from "react";
6
7
  import { Primitive } from "@radix-ui/react-primitive";
7
8
  import { useAddForm } from "./context";
@@ -15,13 +16,26 @@ export const McpAddFormPrimitiveError = forwardRef<
15
16
  McpAddFormPrimitiveError.Element,
16
17
  McpAddFormPrimitiveError.Props
17
18
  >((props, ref) => {
18
- const { state } = useAddForm();
19
+ const { state, ids } = useAddForm();
19
20
  if (!state.error) return null;
20
- return (
21
- <Primitive.div {...props} ref={ref}>
21
+ const childId =
22
+ props.asChild && isValidElement<{ id?: string }>(props.children)
23
+ ? props.children.props.id
24
+ : undefined;
25
+ const customId = childId ?? props.id;
26
+ const needsDescriptionWrapper =
27
+ customId !== undefined && customId !== ids.error;
28
+ const error = (
29
+ <Primitive.div
30
+ {...props}
31
+ id={needsDescriptionWrapper ? props.id : ids.error}
32
+ role={props.role ?? "alert"}
33
+ ref={ref}
34
+ >
22
35
  {props.children ?? state.error}
23
36
  </Primitive.div>
24
37
  );
38
+ return needsDescriptionWrapper ? <div id={ids.error}>{error}</div> : error;
25
39
  });
26
40
 
27
41
  McpAddFormPrimitiveError.displayName = "McpAddFormPrimitive.Error";
@@ -18,12 +18,24 @@ export const McpAddFormPrimitiveNameField = forwardRef<
18
18
  McpAddFormPrimitiveNameField.Element,
19
19
  McpAddFormPrimitiveNameField.Props
20
20
  >((props, ref) => {
21
- const { state, setField } = useAddForm();
21
+ const { state, ids, setField } = useAddForm();
22
22
  return (
23
23
  <Primitive.input
24
24
  type="text"
25
25
  placeholder="Name"
26
26
  {...props}
27
+ aria-invalid={
28
+ props["aria-invalid"] ??
29
+ (state.errorField === "name" ? true : undefined)
30
+ }
31
+ aria-describedby={
32
+ [
33
+ props["aria-describedby"],
34
+ state.errorField === "name" ? ids.error : null,
35
+ ]
36
+ .filter(Boolean)
37
+ .join(" ") || undefined
38
+ }
27
39
  ref={ref}
28
40
  value={state.name}
29
41
  onChange={(e) => setField("name", e.target.value)}
@@ -4,12 +4,17 @@ import {
4
4
  type FormEventHandler,
5
5
  forwardRef,
6
6
  useCallback,
7
+ useId,
7
8
  useMemo,
8
9
  useState,
9
10
  } from "react";
10
11
  import { Primitive } from "@radix-ui/react-primitive";
11
12
  import { useAui } from "@assistant-ui/store";
12
- import { AddFormContext, type AddFormState } from "./context";
13
+ import {
14
+ AddFormContext,
15
+ type AddFormFieldIds,
16
+ type AddFormState,
17
+ } from "./context";
13
18
  import type { MCPAuthConfig } from "../../mcp-scope";
14
19
  import { invokeMcpCallback } from "../../utils/invokeMcpCallback";
15
20
 
@@ -21,6 +26,7 @@ const INITIAL: AddFormState = {
21
26
  scopes: "",
22
27
  submitting: false,
23
28
  error: null,
29
+ errorField: null,
24
30
  };
25
31
 
26
32
  function validateUrl(raw: string): { url: string } | { error: string } {
@@ -54,11 +60,30 @@ export const McpAddFormPrimitiveRoot = forwardRef<
54
60
  McpAddFormPrimitiveRoot.Props
55
61
  >(({ onSubmitted, onCancel, ...props }, ref) => {
56
62
  const aui = useAui();
63
+ const formId = useId();
57
64
  const [state, setState] = useState<AddFormState>(INITIAL);
65
+ const ids = useMemo<AddFormFieldIds>(
66
+ () => ({
67
+ bearerToken: `${formId}-bearer-token`,
68
+ scopes: `${formId}-oauth-scopes`,
69
+ error: `${formId}-error`,
70
+ }),
71
+ [formId],
72
+ );
58
73
 
59
74
  const setField = useCallback(
60
75
  <K extends keyof AddFormState>(key: K, value: AddFormState[K]) => {
61
- setState((prev) => ({ ...prev, [key]: value }));
76
+ setState((prev) => {
77
+ const clearsError =
78
+ prev.errorField === "form" ||
79
+ prev.errorField === key ||
80
+ (key === "authType" && prev.errorField === "bearerToken");
81
+ return {
82
+ ...prev,
83
+ [key]: value,
84
+ ...(clearsError ? { error: null, errorField: null } : {}),
85
+ };
86
+ });
62
87
  },
63
88
  [],
64
89
  );
@@ -87,19 +112,36 @@ export const McpAddFormPrimitiveRoot = forwardRef<
87
112
  const submit = useCallback(async () => {
88
113
  if (state.submitting) return;
89
114
  if (!state.name.trim()) {
90
- setState((p) => ({ ...p, error: "Name is required" }));
115
+ setState((p) => ({
116
+ ...p,
117
+ error: "Name is required",
118
+ errorField: "name",
119
+ }));
91
120
  return;
92
121
  }
93
122
  if (state.authType === "bearer" && !state.bearerToken.trim()) {
94
- setState((p) => ({ ...p, error: "Bearer token is required" }));
123
+ setState((p) => ({
124
+ ...p,
125
+ error: "Bearer token is required",
126
+ errorField: "bearerToken",
127
+ }));
95
128
  return;
96
129
  }
97
130
  const urlResult = validateUrl(state.url);
98
131
  if ("error" in urlResult) {
99
- setState((p) => ({ ...p, error: urlResult.error }));
132
+ setState((p) => ({
133
+ ...p,
134
+ error: urlResult.error,
135
+ errorField: "url",
136
+ }));
100
137
  return;
101
138
  }
102
- setState((p) => ({ ...p, submitting: true, error: null }));
139
+ setState((p) => ({
140
+ ...p,
141
+ submitting: true,
142
+ error: null,
143
+ errorField: null,
144
+ }));
103
145
  try {
104
146
  const id = await aui.mcp.addCustomServer({
105
147
  name: state.name.trim(),
@@ -113,6 +155,7 @@ export const McpAddFormPrimitiveRoot = forwardRef<
113
155
  ...p,
114
156
  submitting: false,
115
157
  error: err instanceof Error ? err.message : String(err),
158
+ errorField: "form",
116
159
  }));
117
160
  }
118
161
  }, [aui, buildAuth, onSubmitted, state]);
@@ -123,8 +166,8 @@ export const McpAddFormPrimitiveRoot = forwardRef<
123
166
  }, [onCancel]);
124
167
 
125
168
  const value = useMemo(
126
- () => ({ state, setField, reset, submit, cancel }),
127
- [state, setField, reset, submit, cancel],
169
+ () => ({ state, ids, setField, reset, submit, cancel }),
170
+ [state, ids, setField, reset, submit, cancel],
128
171
  );
129
172
 
130
173
  const onFormSubmit: FormEventHandler<HTMLFormElement> = (e) => {
@@ -18,13 +18,24 @@ export const McpAddFormPrimitiveUrlField = forwardRef<
18
18
  McpAddFormPrimitiveUrlField.Element,
19
19
  McpAddFormPrimitiveUrlField.Props
20
20
  >((props, ref) => {
21
- const { state, setField } = useAddForm();
21
+ const { state, ids, setField } = useAddForm();
22
22
  return (
23
23
  <Primitive.input
24
24
  type="url"
25
25
  inputMode="url"
26
26
  placeholder="https://example.com/mcp"
27
27
  {...props}
28
+ aria-invalid={
29
+ props["aria-invalid"] ?? (state.errorField === "url" ? true : undefined)
30
+ }
31
+ aria-describedby={
32
+ [
33
+ props["aria-describedby"],
34
+ state.errorField === "url" ? ids.error : null,
35
+ ]
36
+ .filter(Boolean)
37
+ .join(" ") || undefined
38
+ }
28
39
  ref={ref}
29
40
  value={state.url}
30
41
  onChange={(e) => setField("url", e.target.value)}
@@ -3,6 +3,14 @@ import type { MCPAuthConfig } from "../../mcp-scope";
3
3
 
4
4
  export type AddFormAuthType = MCPAuthConfig["type"];
5
5
 
6
+ export type AddFormErrorField = "name" | "url" | "bearerToken" | "form" | null;
7
+
8
+ export type AddFormFieldIds = {
9
+ bearerToken: string;
10
+ scopes: string;
11
+ error: string;
12
+ };
13
+
6
14
  export type AddFormState = {
7
15
  name: string;
8
16
  url: string;
@@ -11,10 +19,12 @@ export type AddFormState = {
11
19
  scopes: string;
12
20
  submitting: boolean;
13
21
  error: string | null;
22
+ errorField: AddFormErrorField;
14
23
  };
15
24
 
16
25
  export type AddFormContextValue = {
17
26
  state: AddFormState;
27
+ ids: AddFormFieldIds;
18
28
  setField: <K extends keyof AddFormState>(
19
29
  key: K,
20
30
  value: AddFormState[K],