@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.
- package/dist/primitives/addForm/McpAddFormAuthFields.js +71 -28
- package/dist/primitives/addForm/McpAddFormAuthFields.js.map +1 -1
- package/dist/primitives/addForm/McpAddFormError.d.ts.map +1 -1
- package/dist/primitives/addForm/McpAddFormError.js +30 -10
- package/dist/primitives/addForm/McpAddFormError.js.map +1 -1
- package/dist/primitives/addForm/McpAddFormNameField.js +31 -18
- package/dist/primitives/addForm/McpAddFormNameField.js.map +1 -1
- package/dist/primitives/addForm/McpAddFormRoot.d.ts.map +1 -1
- package/dist/primitives/addForm/McpAddFormRoot.js +32 -11
- package/dist/primitives/addForm/McpAddFormRoot.js.map +1 -1
- package/dist/primitives/addForm/McpAddFormUrlField.js +31 -18
- package/dist/primitives/addForm/McpAddFormUrlField.js.map +1 -1
- package/dist/primitives/addForm/context.d.ts +9 -1
- package/dist/primitives/addForm/context.d.ts.map +1 -1
- package/dist/primitives/addForm/context.js.map +1 -1
- package/dist/resources/McpManagerResource.d.ts.map +1 -1
- package/dist/resources/McpManagerResource.js +330 -247
- package/dist/resources/McpManagerResource.js.map +1 -1
- package/dist/resources/McpServerRemovalFence.d.ts +7 -0
- package/dist/resources/McpServerRemovalFence.d.ts.map +1 -0
- package/dist/resources/McpServerRemovalFence.js +11 -0
- package/dist/resources/McpServerRemovalFence.js.map +1 -0
- package/dist/resources/McpServerResource.d.ts.map +1 -1
- package/dist/resources/McpServerResource.js +5 -1
- package/dist/resources/McpServerResource.js.map +1 -1
- package/dist/resources/storage/McpLocalStorage.d.ts.map +1 -1
- package/dist/resources/storage/McpLocalStorage.js +2 -1
- package/dist/resources/storage/McpLocalStorage.js.map +1 -1
- package/dist/resources/storage/types.d.ts +10 -8
- package/dist/resources/storage/types.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/primitives/addForm/McpAddFormAccessibility.test.tsx +261 -0
- package/src/primitives/addForm/McpAddFormAuthFields.tsx +33 -15
- package/src/primitives/addForm/McpAddFormError.tsx +17 -3
- package/src/primitives/addForm/McpAddFormNameField.tsx +13 -1
- package/src/primitives/addForm/McpAddFormRoot.tsx +51 -8
- package/src/primitives/addForm/McpAddFormUrlField.tsx +12 -1
- package/src/primitives/addForm/context.tsx +10 -0
- package/src/resources/McpManagerResource.test.ts +712 -9
- package/src/resources/McpManagerResource.ts +205 -56
- package/src/resources/McpServerRemovalFence.ts +21 -0
- package/src/resources/McpServerResource.ts +5 -1
- package/src/resources/storage/McpLocalStorage.test.ts +2 -0
- package/src/resources/storage/McpLocalStorage.ts +2 -1
- 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.
|
|
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.
|
|
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.
|
|
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.
|
|
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": "^
|
|
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
|
-
<
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
<
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
21
|
-
|
|
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 {
|
|
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) =>
|
|
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) => ({
|
|
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) => ({
|
|
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) => ({
|
|
132
|
+
setState((p) => ({
|
|
133
|
+
...p,
|
|
134
|
+
error: urlResult.error,
|
|
135
|
+
errorField: "url",
|
|
136
|
+
}));
|
|
100
137
|
return;
|
|
101
138
|
}
|
|
102
|
-
setState((p) => ({
|
|
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],
|