@assistant-ui/react-mcp 0.1.17 → 0.1.19
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/auth/buildHeaders.d.ts +1 -2
- package/dist/auth/buildHeaders.d.ts.map +1 -1
- package/dist/auth/createOAuthProvider.d.ts +5 -6
- package/dist/auth/createOAuthProvider.d.ts.map +1 -1
- package/dist/auth/types.d.ts +1 -2
- package/dist/auth/types.d.ts.map +1 -1
- package/dist/connector.d.ts +1 -2
- package/dist/connector.d.ts.map +1 -1
- package/dist/context/McpConnectorByIndexProvider.d.ts +1 -2
- package/dist/context/McpConnectorByIndexProvider.d.ts.map +1 -1
- package/dist/context/McpCustomServerByIndexProvider.d.ts +1 -2
- package/dist/context/McpCustomServerByIndexProvider.d.ts.map +1 -1
- package/dist/context/McpServerByIdProvider.d.ts +1 -2
- package/dist/context/McpServerByIdProvider.d.ts.map +1 -1
- package/dist/hooks/useMcpOAuthCallback.d.ts +5 -6
- package/dist/hooks/useMcpOAuthCallback.d.ts.map +1 -1
- package/dist/hooks/useMcpOAuthCallback.js +4 -0
- package/dist/hooks/useMcpOAuthCallback.js.map +1 -1
- package/dist/mcp-scope.d.ts +14 -15
- package/dist/mcp-scope.d.ts.map +1 -1
- package/dist/primitives/addForm/McpAddFormAuthFields.d.ts +4 -4
- package/dist/primitives/addForm/McpAddFormAuthFields.d.ts.map +1 -1
- package/dist/primitives/addForm/McpAddFormAuthFields.js +28 -38
- package/dist/primitives/addForm/McpAddFormAuthFields.js.map +1 -1
- package/dist/primitives/addForm/McpAddFormAuthSelect.d.ts +2 -3
- package/dist/primitives/addForm/McpAddFormAuthSelect.d.ts.map +1 -1
- package/dist/primitives/addForm/McpAddFormBearerTokenField.d.ts +10 -0
- package/dist/primitives/addForm/McpAddFormBearerTokenField.d.ts.map +1 -0
- package/dist/primitives/addForm/McpAddFormBearerTokenField.js +50 -0
- package/dist/primitives/addForm/McpAddFormBearerTokenField.js.map +1 -0
- package/dist/primitives/addForm/McpAddFormCancel.d.ts +2 -3
- package/dist/primitives/addForm/McpAddFormCancel.d.ts.map +1 -1
- package/dist/primitives/addForm/McpAddFormError.d.ts +2 -3
- 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.d.ts +2 -3
- package/dist/primitives/addForm/McpAddFormNameField.d.ts.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 +2 -3
- 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/McpAddFormScopesField.d.ts +10 -0
- package/dist/primitives/addForm/McpAddFormScopesField.d.ts.map +1 -0
- package/dist/primitives/addForm/McpAddFormScopesField.js +38 -0
- package/dist/primitives/addForm/McpAddFormScopesField.js.map +1 -0
- package/dist/primitives/addForm/McpAddFormSubmit.d.ts +2 -3
- package/dist/primitives/addForm/McpAddFormSubmit.d.ts.map +1 -1
- package/dist/primitives/addForm/McpAddFormUrlField.d.ts +2 -3
- package/dist/primitives/addForm/McpAddFormUrlField.d.ts.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 +13 -6
- package/dist/primitives/addForm/context.d.ts.map +1 -1
- package/dist/primitives/addForm/context.js.map +1 -1
- package/dist/primitives/addForm.d.ts +4 -2
- package/dist/primitives/addForm.js +5 -1
- package/dist/primitives/addForm.js.map +1 -1
- package/dist/primitives/elicitation/McpElicitationPrimitiveAccept.d.ts +2 -3
- package/dist/primitives/elicitation/McpElicitationPrimitiveAccept.d.ts.map +1 -1
- package/dist/primitives/elicitation/McpElicitationPrimitiveCancel.d.ts +2 -3
- package/dist/primitives/elicitation/McpElicitationPrimitiveCancel.d.ts.map +1 -1
- package/dist/primitives/elicitation/McpElicitationPrimitiveDecline.d.ts +2 -3
- package/dist/primitives/elicitation/McpElicitationPrimitiveDecline.d.ts.map +1 -1
- package/dist/primitives/elicitation/McpElicitationPrimitiveError.d.ts +2 -3
- package/dist/primitives/elicitation/McpElicitationPrimitiveError.d.ts.map +1 -1
- package/dist/primitives/elicitation/McpElicitationPrimitiveFields.d.ts +3 -3
- package/dist/primitives/elicitation/McpElicitationPrimitiveFields.d.ts.map +1 -1
- package/dist/primitives/elicitation/McpElicitationPrimitiveItems.d.ts +3 -3
- package/dist/primitives/elicitation/McpElicitationPrimitiveItems.d.ts.map +1 -1
- package/dist/primitives/elicitation/McpElicitationPrimitiveMessage.d.ts +2 -3
- package/dist/primitives/elicitation/McpElicitationPrimitiveMessage.d.ts.map +1 -1
- package/dist/primitives/elicitation/McpElicitationPrimitiveRoot.d.ts +2 -3
- package/dist/primitives/elicitation/McpElicitationPrimitiveRoot.d.ts.map +1 -1
- package/dist/primitives/elicitation/context.d.ts +7 -8
- package/dist/primitives/elicitation/context.d.ts.map +1 -1
- package/dist/primitives/elicitation/initialElicitationDraft.d.ts +1 -2
- package/dist/primitives/elicitation/initialElicitationDraft.d.ts.map +1 -1
- package/dist/primitives/elicitation/prepareElicitationContent.d.ts +1 -2
- package/dist/primitives/elicitation/prepareElicitationContent.d.ts.map +1 -1
- package/dist/primitives/elicitation/prepareElicitationContent.js +5 -1
- package/dist/primitives/elicitation/prepareElicitationContent.js.map +1 -1
- package/dist/primitives/manager/McpManagerAddCustomTrigger.d.ts +2 -3
- package/dist/primitives/manager/McpManagerAddCustomTrigger.d.ts.map +1 -1
- package/dist/primitives/manager/McpManagerConnectors.d.ts +2 -3
- package/dist/primitives/manager/McpManagerConnectors.d.ts.map +1 -1
- package/dist/primitives/manager/McpManagerCustomServers.d.ts +2 -3
- package/dist/primitives/manager/McpManagerCustomServers.d.ts.map +1 -1
- package/dist/primitives/manager/McpManagerRoot.d.ts +2 -3
- package/dist/primitives/manager/McpManagerRoot.d.ts.map +1 -1
- package/dist/primitives/server/McpServerConnectButton.d.ts +2 -3
- package/dist/primitives/server/McpServerConnectButton.d.ts.map +1 -1
- package/dist/primitives/server/McpServerDisconnectButton.d.ts +2 -3
- package/dist/primitives/server/McpServerDisconnectButton.d.ts.map +1 -1
- package/dist/primitives/server/McpServerError.d.ts +2 -3
- package/dist/primitives/server/McpServerError.d.ts.map +1 -1
- package/dist/primitives/server/McpServerIcon.d.ts +2 -3
- package/dist/primitives/server/McpServerIcon.d.ts.map +1 -1
- package/dist/primitives/server/McpServerName.d.ts +2 -3
- package/dist/primitives/server/McpServerName.d.ts.map +1 -1
- package/dist/primitives/server/McpServerOAuthLink.d.ts +2 -3
- package/dist/primitives/server/McpServerOAuthLink.d.ts.map +1 -1
- package/dist/primitives/server/McpServerRemoveButton.d.ts +2 -3
- package/dist/primitives/server/McpServerRemoveButton.d.ts.map +1 -1
- package/dist/primitives/server/McpServerRoot.d.ts +2 -3
- package/dist/primitives/server/McpServerRoot.d.ts.map +1 -1
- package/dist/primitives/server/McpServerStatus.d.ts +2 -3
- package/dist/primitives/server/McpServerStatus.d.ts.map +1 -1
- package/dist/primitives/server/McpServerToolName.d.ts +2 -3
- package/dist/primitives/server/McpServerToolName.d.ts.map +1 -1
- package/dist/primitives/server/McpServerTools.d.ts +3 -4
- package/dist/primitives/server/McpServerTools.d.ts.map +1 -1
- package/dist/resources/McpManagerResource.d.ts +2 -3
- package/dist/resources/McpManagerResource.d.ts.map +1 -1
- package/dist/resources/McpManagerResource.js +358 -244
- package/dist/resources/McpManagerResource.js.map +1 -1
- package/dist/resources/McpServerRemovalFence.d.ts +6 -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 +3 -4
- package/dist/resources/McpServerResource.d.ts.map +1 -1
- package/dist/resources/McpServerResource.js +9 -5
- package/dist/resources/McpServerResource.js.map +1 -1
- package/dist/resources/storage/McpCustomStorage.d.ts +1 -2
- package/dist/resources/storage/McpCustomStorage.d.ts.map +1 -1
- package/dist/resources/storage/McpLocalStorage.d.ts +4 -5
- 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/McpMemoryStorage.d.ts +1 -2
- package/dist/resources/storage/McpMemoryStorage.d.ts.map +1 -1
- package/dist/resources/storage/types.d.ts +12 -11
- package/dist/resources/storage/types.d.ts.map +1 -1
- package/dist/resources/validateElicitationContent.d.ts +1 -2
- package/dist/resources/validateElicitationContent.d.ts.map +1 -1
- package/dist/utils/createMcpId.d.ts +1 -2
- package/dist/utils/createMcpId.d.ts.map +1 -1
- package/dist/utils/invokeMcpCallback.d.ts +1 -2
- package/dist/utils/invokeMcpCallback.d.ts.map +1 -1
- package/dist/utils/serverId.d.ts +2 -3
- package/dist/utils/serverId.d.ts.map +1 -1
- package/dist/utils/serverUrl.d.ts +3 -4
- package/dist/utils/serverUrl.d.ts.map +1 -1
- package/package.json +11 -10
- package/src/hooks/useMcpOAuthCallback.test.ts +53 -0
- package/src/hooks/useMcpOAuthCallback.tsx +12 -0
- package/src/index.ts +2 -1
- package/src/primitives/addForm/McpAddFormAccessibility.test.tsx +310 -0
- package/src/primitives/addForm/McpAddFormAuthFields.tsx +26 -16
- package/src/primitives/addForm/McpAddFormBearerTokenField.tsx +46 -0
- package/src/primitives/addForm/McpAddFormError.tsx +17 -3
- package/src/primitives/addForm/McpAddFormNameField.tsx +13 -1
- package/src/primitives/addForm/McpAddFormRoot.test.tsx +65 -0
- package/src/primitives/addForm/McpAddFormRoot.tsx +51 -8
- package/src/primitives/addForm/McpAddFormScopesField.tsx +34 -0
- package/src/primitives/addForm/McpAddFormUrlField.tsx +12 -1
- package/src/primitives/addForm/context.tsx +10 -0
- package/src/primitives/addForm.ts +2 -0
- package/src/primitives/elicitation/prepareElicitationContent.ts +8 -6
- package/src/resources/McpManagerResource.test.ts +797 -9
- package/src/resources/McpManagerResource.ts +242 -60
- package/src/resources/McpServerRemovalFence.ts +21 -0
- package/src/resources/McpServerResource.test.ts +9 -6
- package/src/resources/McpServerResource.ts +11 -6
- 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
|
@@ -0,0 +1,310 @@
|
|
|
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 { McpAddFormPrimitiveBearerTokenField as BearerTokenField } from "./McpAddFormBearerTokenField";
|
|
26
|
+
import { McpAddFormPrimitiveScopesField as ScopesField } from "./McpAddFormScopesField";
|
|
27
|
+
import { McpAddFormPrimitiveError as ErrorMessage } from "./McpAddFormError";
|
|
28
|
+
|
|
29
|
+
const Form = ({ name = "Add server" }: { name?: string }) => (
|
|
30
|
+
<Root aria-label={name}>
|
|
31
|
+
<label>
|
|
32
|
+
Name
|
|
33
|
+
<NameField />
|
|
34
|
+
</label>
|
|
35
|
+
<label>
|
|
36
|
+
URL
|
|
37
|
+
<UrlField />
|
|
38
|
+
</label>
|
|
39
|
+
<label>
|
|
40
|
+
Auth
|
|
41
|
+
<AuthSelect />
|
|
42
|
+
</label>
|
|
43
|
+
<AuthFields />
|
|
44
|
+
<ErrorMessage />
|
|
45
|
+
</Root>
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
mocks.addCustomServer.mockReset().mockResolvedValue("server");
|
|
50
|
+
});
|
|
51
|
+
afterEach(cleanup);
|
|
52
|
+
|
|
53
|
+
describe("MCP add form accessibility", () => {
|
|
54
|
+
it("does not add a wrapper around an asChild error without a custom ID", () => {
|
|
55
|
+
render(
|
|
56
|
+
<Root aria-label="Add server">
|
|
57
|
+
<NameField aria-label="Name" />
|
|
58
|
+
<ErrorMessage asChild>
|
|
59
|
+
<div>Enter the server name.</div>
|
|
60
|
+
</ErrorMessage>
|
|
61
|
+
</Root>,
|
|
62
|
+
);
|
|
63
|
+
fireEvent.submit(screen.getByRole("form"));
|
|
64
|
+
const error = screen.getByRole("alert");
|
|
65
|
+
expect(error.parentElement).toBe(screen.getByRole("form"));
|
|
66
|
+
expect(error.id).not.toBe("");
|
|
67
|
+
expect(
|
|
68
|
+
screen
|
|
69
|
+
.getByRole("textbox", { name: "Name" })
|
|
70
|
+
.getAttribute("aria-describedby"),
|
|
71
|
+
).toBe(error.id);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it.each(["element", "slot", "child"] as const)(
|
|
75
|
+
"keeps a custom error ID on the %s associated with fields",
|
|
76
|
+
(target) => {
|
|
77
|
+
const ref = createRef<HTMLDivElement>();
|
|
78
|
+
render(
|
|
79
|
+
<Root aria-label="Add server">
|
|
80
|
+
<NameField aria-label="Name" />
|
|
81
|
+
<ErrorMessage
|
|
82
|
+
{...(target !== "child" && { id: "custom-error" })}
|
|
83
|
+
asChild={target !== "element"}
|
|
84
|
+
ref={ref}
|
|
85
|
+
>
|
|
86
|
+
{target !== "element" ? (
|
|
87
|
+
<div {...(target === "child" && { id: "custom-error" })}>
|
|
88
|
+
Enter the server name.
|
|
89
|
+
</div>
|
|
90
|
+
) : (
|
|
91
|
+
"Enter the server name."
|
|
92
|
+
)}
|
|
93
|
+
</ErrorMessage>
|
|
94
|
+
</Root>,
|
|
95
|
+
);
|
|
96
|
+
fireEvent.submit(screen.getByRole("form"));
|
|
97
|
+
const error = screen.getByRole("alert");
|
|
98
|
+
expect(error.id).toBe("custom-error");
|
|
99
|
+
expect(ref.current).toBe(error);
|
|
100
|
+
const descriptionId = screen
|
|
101
|
+
.getByRole("textbox", { name: "Name" })
|
|
102
|
+
.getAttribute("aria-describedby")!;
|
|
103
|
+
expect(document.getElementById(descriptionId)?.textContent).toBe(
|
|
104
|
+
"Enter the server name.",
|
|
105
|
+
);
|
|
106
|
+
expect(document.querySelectorAll(`[id="${descriptionId}"]`)).toHaveLength(
|
|
107
|
+
1,
|
|
108
|
+
);
|
|
109
|
+
},
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
it("keeps enclosing labels as the accessible names", () => {
|
|
113
|
+
render(
|
|
114
|
+
<Root>
|
|
115
|
+
<label>
|
|
116
|
+
Server name
|
|
117
|
+
<NameField />
|
|
118
|
+
</label>
|
|
119
|
+
<label>
|
|
120
|
+
Server address
|
|
121
|
+
<UrlField />
|
|
122
|
+
</label>
|
|
123
|
+
<label>
|
|
124
|
+
Authentication method
|
|
125
|
+
<AuthSelect />
|
|
126
|
+
</label>
|
|
127
|
+
<label>
|
|
128
|
+
Permissions
|
|
129
|
+
<ScopesField />
|
|
130
|
+
</label>
|
|
131
|
+
</Root>,
|
|
132
|
+
);
|
|
133
|
+
expect(screen.getByRole("textbox", { name: "Server name" })).toBeTruthy();
|
|
134
|
+
expect(
|
|
135
|
+
screen.getByRole("textbox", { name: "Server address" }),
|
|
136
|
+
).toBeTruthy();
|
|
137
|
+
expect(
|
|
138
|
+
screen.getByRole("combobox", { name: "Authentication method" }),
|
|
139
|
+
).toBeTruthy();
|
|
140
|
+
expect(screen.getByRole("textbox", { name: "Permissions" })).toBeTruthy();
|
|
141
|
+
});
|
|
142
|
+
it("preserves field labels and only marks the field with an error", () => {
|
|
143
|
+
render(<Form />);
|
|
144
|
+
const name = screen.getByRole("textbox", { name: "Name" });
|
|
145
|
+
const url = screen.getByRole("textbox", { name: "URL" });
|
|
146
|
+
const auth = screen.getByRole("combobox", { name: "Auth" });
|
|
147
|
+
expect(screen.getByRole("textbox", { name: "OAuth scopes" })).toBeTruthy();
|
|
148
|
+
|
|
149
|
+
fireEvent.submit(screen.getByRole("form"));
|
|
150
|
+
const error = screen.getByRole("alert");
|
|
151
|
+
expect(error.textContent).toBe("Name is required");
|
|
152
|
+
expect(name.getAttribute("aria-invalid")).toBe("true");
|
|
153
|
+
expect(name.getAttribute("aria-describedby")).toBe(error.id);
|
|
154
|
+
expect(url.hasAttribute("aria-invalid")).toBe(false);
|
|
155
|
+
|
|
156
|
+
fireEvent.change(name, { target: { value: "Docs" } });
|
|
157
|
+
expect(screen.queryByRole("alert")).toBeNull();
|
|
158
|
+
expect(name.hasAttribute("aria-invalid")).toBe(false);
|
|
159
|
+
fireEvent.submit(screen.getByRole("form"));
|
|
160
|
+
expect(url.getAttribute("aria-describedby")).toBe(
|
|
161
|
+
screen.getByRole("alert").id,
|
|
162
|
+
);
|
|
163
|
+
fireEvent.change(url, { target: { value: "https://example.com/mcp" } });
|
|
164
|
+
fireEvent.change(auth, { target: { value: "bearer" } });
|
|
165
|
+
fireEvent.submit(screen.getByRole("form"));
|
|
166
|
+
const token = screen.getByLabelText("Bearer token");
|
|
167
|
+
expect(token.getAttribute("aria-invalid")).toBe("true");
|
|
168
|
+
expect(token.getAttribute("aria-describedby")).toBe(
|
|
169
|
+
screen.getByRole("alert").id,
|
|
170
|
+
);
|
|
171
|
+
fireEvent.change(auth, { target: { value: "none" } });
|
|
172
|
+
expect(screen.queryByRole("alert")).toBeNull();
|
|
173
|
+
expect(screen.queryByLabelText("Bearer token")).toBeNull();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("announces server errors without marking unrelated fields invalid", async () => {
|
|
177
|
+
mocks.addCustomServer.mockRejectedValue(new Error("Connection failed"));
|
|
178
|
+
render(<Form />);
|
|
179
|
+
fireEvent.change(screen.getByRole("textbox", { name: "Name" }), {
|
|
180
|
+
target: { value: "Docs" },
|
|
181
|
+
});
|
|
182
|
+
fireEvent.change(screen.getByRole("textbox", { name: "URL" }), {
|
|
183
|
+
target: { value: "https://example.com/mcp" },
|
|
184
|
+
});
|
|
185
|
+
fireEvent.submit(screen.getByRole("form"));
|
|
186
|
+
await waitFor(() =>
|
|
187
|
+
expect(screen.getByRole("alert").textContent).toBe("Connection failed"),
|
|
188
|
+
);
|
|
189
|
+
expect(document.querySelector('[aria-invalid="true"]')).toBeNull();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("uses distinct IDs for simultaneously mounted forms", () => {
|
|
193
|
+
render(
|
|
194
|
+
<>
|
|
195
|
+
<Form name="First" />
|
|
196
|
+
<Form name="Second" />
|
|
197
|
+
</>,
|
|
198
|
+
);
|
|
199
|
+
const first = screen.getByRole("form", { name: "First" });
|
|
200
|
+
const second = screen.getByRole("form", { name: "Second" });
|
|
201
|
+
fireEvent.submit(first);
|
|
202
|
+
fireEvent.submit(second);
|
|
203
|
+
const firstError = within(first).getByRole("alert");
|
|
204
|
+
const secondError = within(second).getByRole("alert");
|
|
205
|
+
expect(firstError.id).not.toBe(secondError.id);
|
|
206
|
+
expect(
|
|
207
|
+
within(first)
|
|
208
|
+
.getByRole("textbox", { name: "Name" })
|
|
209
|
+
.getAttribute("aria-describedby"),
|
|
210
|
+
).toBe(firstError.id);
|
|
211
|
+
expect(
|
|
212
|
+
within(second)
|
|
213
|
+
.getByRole("textbox", { name: "Name" })
|
|
214
|
+
.getAttribute("aria-describedby"),
|
|
215
|
+
).toBe(secondError.id);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("preserves caller IDs and ARIA overrides while associating errors", () => {
|
|
219
|
+
render(
|
|
220
|
+
<Root aria-label="Add server">
|
|
221
|
+
<NameField
|
|
222
|
+
id="server-name"
|
|
223
|
+
aria-label="Server name"
|
|
224
|
+
aria-describedby="hint"
|
|
225
|
+
aria-invalid={false}
|
|
226
|
+
/>
|
|
227
|
+
<p id="hint">Use a recognizable name.</p>
|
|
228
|
+
<ErrorMessage />
|
|
229
|
+
</Root>,
|
|
230
|
+
);
|
|
231
|
+
const field = screen.getByRole("textbox", { name: "Server name" });
|
|
232
|
+
expect(field.id).toBe("server-name");
|
|
233
|
+
expect(field.getAttribute("aria-describedby")).toBe("hint");
|
|
234
|
+
fireEvent.submit(screen.getByRole("form"));
|
|
235
|
+
expect(field.getAttribute("aria-invalid")).toBe("false");
|
|
236
|
+
expect(field.getAttribute("aria-describedby")).toBe(
|
|
237
|
+
`hint ${screen.getByRole("alert").id}`,
|
|
238
|
+
);
|
|
239
|
+
fireEvent.change(field, { target: { value: "Docs" } });
|
|
240
|
+
expect(screen.queryByRole("alert")).toBeNull();
|
|
241
|
+
expect(field.getAttribute("aria-describedby")).toBe("hint");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("associates a missing token error with a custom bearer token field", () => {
|
|
245
|
+
render(
|
|
246
|
+
<Root aria-label="Add server">
|
|
247
|
+
<NameField aria-label="Name" />
|
|
248
|
+
<AuthSelect aria-label="Auth" />
|
|
249
|
+
<AuthFields>
|
|
250
|
+
{({ authType }) =>
|
|
251
|
+
authType === "bearer" ? (
|
|
252
|
+
<>
|
|
253
|
+
<label htmlFor="api-key">API key</label>
|
|
254
|
+
<BearerTokenField
|
|
255
|
+
id="api-key"
|
|
256
|
+
aria-describedby="api-key-hint"
|
|
257
|
+
/>
|
|
258
|
+
<p id="api-key-hint">Paste the key from your dashboard.</p>
|
|
259
|
+
</>
|
|
260
|
+
) : null
|
|
261
|
+
}
|
|
262
|
+
</AuthFields>
|
|
263
|
+
<ErrorMessage />
|
|
264
|
+
</Root>,
|
|
265
|
+
);
|
|
266
|
+
fireEvent.change(screen.getByRole("textbox", { name: "Name" }), {
|
|
267
|
+
target: { value: "Docs" },
|
|
268
|
+
});
|
|
269
|
+
fireEvent.change(screen.getByRole("combobox", { name: "Auth" }), {
|
|
270
|
+
target: { value: "bearer" },
|
|
271
|
+
});
|
|
272
|
+
const field = screen.getByLabelText("API key");
|
|
273
|
+
fireEvent.submit(screen.getByRole("form"));
|
|
274
|
+
const error = screen.getByRole("alert");
|
|
275
|
+
expect(error.textContent).toBe("Bearer token is required");
|
|
276
|
+
expect(field.getAttribute("aria-invalid")).toBe("true");
|
|
277
|
+
expect(field.getAttribute("aria-describedby")).toBe(
|
|
278
|
+
`api-key-hint ${error.id}`,
|
|
279
|
+
);
|
|
280
|
+
fireEvent.change(field, { target: { value: "secret" } });
|
|
281
|
+
expect(screen.queryByRole("alert")).toBeNull();
|
|
282
|
+
expect(field.hasAttribute("aria-invalid")).toBe(false);
|
|
283
|
+
expect(field.getAttribute("aria-describedby")).toBe("api-key-hint");
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("keeps URL help text associated when validation errors appear and clear", () => {
|
|
287
|
+
render(
|
|
288
|
+
<Root aria-label="Add server">
|
|
289
|
+
<NameField aria-label="Name" />
|
|
290
|
+
<UrlField aria-label="URL" aria-describedby="url-hint url-format" />
|
|
291
|
+
<p id="url-hint">Use the server endpoint.</p>
|
|
292
|
+
<p id="url-format">An HTTP or HTTPS URL is required.</p>
|
|
293
|
+
<ErrorMessage />
|
|
294
|
+
</Root>,
|
|
295
|
+
);
|
|
296
|
+
fireEvent.change(screen.getByRole("textbox", { name: "Name" }), {
|
|
297
|
+
target: { value: "Docs" },
|
|
298
|
+
});
|
|
299
|
+
const field = screen.getByRole("textbox", { name: "URL" });
|
|
300
|
+
fireEvent.submit(screen.getByRole("form"));
|
|
301
|
+
expect(field.getAttribute("aria-invalid")).toBe("true");
|
|
302
|
+
expect(field.getAttribute("aria-describedby")).toBe(
|
|
303
|
+
`url-hint url-format ${screen.getByRole("alert").id}`,
|
|
304
|
+
);
|
|
305
|
+
fireEvent.change(field, { target: { value: "https://example.com/mcp" } });
|
|
306
|
+
expect(screen.queryByRole("alert")).toBeNull();
|
|
307
|
+
expect(field.hasAttribute("aria-invalid")).toBe(false);
|
|
308
|
+
expect(field.getAttribute("aria-describedby")).toBe("url-hint url-format");
|
|
309
|
+
});
|
|
310
|
+
});
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import type { FC } from "react";
|
|
2
|
+
import { McpAddFormPrimitiveBearerTokenField } from "./McpAddFormBearerTokenField";
|
|
3
|
+
import { McpAddFormPrimitiveScopesField } from "./McpAddFormScopesField";
|
|
2
4
|
import { type AddFormAuthType, useAddForm } from "./context";
|
|
3
5
|
|
|
4
6
|
export namespace McpAddFormPrimitiveAuthFields {
|
|
5
7
|
export type Props = {
|
|
6
8
|
/**
|
|
7
9
|
* Optional render override. Receives the current auth type so apps can render
|
|
8
|
-
* fully custom inputs
|
|
10
|
+
* fully custom inputs, bound to the form through `BearerTokenField` and
|
|
11
|
+
* `ScopesField`. Defaults to a minimal built-in for bearer / oauth.
|
|
9
12
|
*/
|
|
10
13
|
children?: FC<{ authType: AddFormAuthType }>;
|
|
11
14
|
};
|
|
@@ -14,7 +17,7 @@ export namespace McpAddFormPrimitiveAuthFields {
|
|
|
14
17
|
export const McpAddFormPrimitiveAuthFields: FC<
|
|
15
18
|
McpAddFormPrimitiveAuthFields.Props
|
|
16
19
|
> = ({ children }) => {
|
|
17
|
-
const { state,
|
|
20
|
+
const { state, ids } = useAddForm();
|
|
18
21
|
|
|
19
22
|
if (children) {
|
|
20
23
|
const Render = children;
|
|
@@ -23,25 +26,32 @@ export const McpAddFormPrimitiveAuthFields: FC<
|
|
|
23
26
|
|
|
24
27
|
if (state.authType === "bearer") {
|
|
25
28
|
return (
|
|
26
|
-
<
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
<div>
|
|
30
|
+
<label
|
|
31
|
+
htmlFor={ids.bearerToken}
|
|
32
|
+
data-mcp-auth-field-label="bearer-token"
|
|
33
|
+
>
|
|
34
|
+
Bearer token
|
|
35
|
+
</label>
|
|
36
|
+
<McpAddFormPrimitiveBearerTokenField
|
|
37
|
+
id={ids.bearerToken}
|
|
38
|
+
data-mcp-auth-field="bearer-token"
|
|
39
|
+
/>
|
|
40
|
+
</div>
|
|
33
41
|
);
|
|
34
42
|
}
|
|
35
43
|
|
|
36
44
|
if (state.authType === "oauth") {
|
|
37
45
|
return (
|
|
38
|
-
<
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
46
|
+
<div>
|
|
47
|
+
<label htmlFor={ids.scopes} data-mcp-auth-field-label="oauth-scopes">
|
|
48
|
+
OAuth scopes
|
|
49
|
+
</label>
|
|
50
|
+
<McpAddFormPrimitiveScopesField
|
|
51
|
+
id={ids.scopes}
|
|
52
|
+
data-mcp-auth-field="oauth-scopes"
|
|
53
|
+
/>
|
|
54
|
+
</div>
|
|
45
55
|
);
|
|
46
56
|
}
|
|
47
57
|
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ComponentPropsWithoutRef,
|
|
3
|
+
type ComponentRef,
|
|
4
|
+
forwardRef,
|
|
5
|
+
} from "react";
|
|
6
|
+
import { Primitive } from "@radix-ui/react-primitive";
|
|
7
|
+
import { useAddForm } from "./context";
|
|
8
|
+
|
|
9
|
+
export namespace McpAddFormPrimitiveBearerTokenField {
|
|
10
|
+
export type Element = ComponentRef<typeof Primitive.input>;
|
|
11
|
+
export type Props = Omit<
|
|
12
|
+
ComponentPropsWithoutRef<typeof Primitive.input>,
|
|
13
|
+
"value" | "onChange" | "type"
|
|
14
|
+
>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const McpAddFormPrimitiveBearerTokenField = forwardRef<
|
|
18
|
+
McpAddFormPrimitiveBearerTokenField.Element,
|
|
19
|
+
McpAddFormPrimitiveBearerTokenField.Props
|
|
20
|
+
>((props, ref) => {
|
|
21
|
+
const { state, ids, setField } = useAddForm();
|
|
22
|
+
return (
|
|
23
|
+
<Primitive.input
|
|
24
|
+
type="password"
|
|
25
|
+
{...props}
|
|
26
|
+
aria-invalid={
|
|
27
|
+
props["aria-invalid"] ??
|
|
28
|
+
(state.errorField === "bearerToken" ? true : undefined)
|
|
29
|
+
}
|
|
30
|
+
aria-describedby={
|
|
31
|
+
[
|
|
32
|
+
props["aria-describedby"],
|
|
33
|
+
state.errorField === "bearerToken" ? ids.error : null,
|
|
34
|
+
]
|
|
35
|
+
.filter(Boolean)
|
|
36
|
+
.join(" ") || undefined
|
|
37
|
+
}
|
|
38
|
+
ref={ref}
|
|
39
|
+
value={state.bearerToken}
|
|
40
|
+
onChange={(e) => setField("bearerToken", e.target.value)}
|
|
41
|
+
/>
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
McpAddFormPrimitiveBearerTokenField.displayName =
|
|
46
|
+
"McpAddFormPrimitive.BearerTokenField";
|
|
@@ -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)}
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
screen,
|
|
8
8
|
waitFor,
|
|
9
9
|
} from "@testing-library/react";
|
|
10
|
+
import type { ComponentProps } from "react";
|
|
10
11
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
11
12
|
|
|
12
13
|
const mocks = vi.hoisted(() => ({
|
|
@@ -20,12 +21,18 @@ vi.mock("@assistant-ui/store", async (importOriginal) => ({
|
|
|
20
21
|
}),
|
|
21
22
|
}));
|
|
22
23
|
|
|
24
|
+
import { McpAddFormPrimitiveAuthFields } from "./McpAddFormAuthFields";
|
|
25
|
+
import { McpAddFormPrimitiveAuthSelect } from "./McpAddFormAuthSelect";
|
|
26
|
+
import { McpAddFormPrimitiveBearerTokenField } from "./McpAddFormBearerTokenField";
|
|
23
27
|
import { McpAddFormPrimitiveError } from "./McpAddFormError";
|
|
24
28
|
import { McpAddFormPrimitiveNameField } from "./McpAddFormNameField";
|
|
25
29
|
import { McpAddFormPrimitiveRoot } from "./McpAddFormRoot";
|
|
30
|
+
import { McpAddFormPrimitiveScopesField } from "./McpAddFormScopesField";
|
|
26
31
|
import { McpAddFormPrimitiveSubmit } from "./McpAddFormSubmit";
|
|
27
32
|
import { McpAddFormPrimitiveUrlField } from "./McpAddFormUrlField";
|
|
28
33
|
|
|
34
|
+
const CustomInput = (props: ComponentProps<"input">) => <input {...props} />;
|
|
35
|
+
|
|
29
36
|
describe("McpAddFormPrimitiveRoot", () => {
|
|
30
37
|
beforeEach(() => {
|
|
31
38
|
mocks.addCustomServer.mockReset();
|
|
@@ -37,6 +44,64 @@ describe("McpAddFormPrimitiveRoot", () => {
|
|
|
37
44
|
vi.restoreAllMocks();
|
|
38
45
|
});
|
|
39
46
|
|
|
47
|
+
it.each([
|
|
48
|
+
[
|
|
49
|
+
"bearer",
|
|
50
|
+
McpAddFormPrimitiveBearerTokenField,
|
|
51
|
+
"secret",
|
|
52
|
+
{ type: "bearer", token: "secret" },
|
|
53
|
+
],
|
|
54
|
+
[
|
|
55
|
+
"oauth",
|
|
56
|
+
McpAddFormPrimitiveScopesField,
|
|
57
|
+
"read, write",
|
|
58
|
+
{ type: "oauth", scopes: ["read", "write"] },
|
|
59
|
+
],
|
|
60
|
+
] as const)(
|
|
61
|
+
"submits a custom %s input bound through its field part",
|
|
62
|
+
async (authType, Field, value, auth) => {
|
|
63
|
+
render(
|
|
64
|
+
<McpAddFormPrimitiveRoot>
|
|
65
|
+
<McpAddFormPrimitiveNameField aria-label="Name" />
|
|
66
|
+
<McpAddFormPrimitiveUrlField aria-label="URL" />
|
|
67
|
+
<McpAddFormPrimitiveAuthSelect aria-label="Auth" />
|
|
68
|
+
<McpAddFormPrimitiveAuthFields>
|
|
69
|
+
{({ authType: current }) =>
|
|
70
|
+
current === authType ? (
|
|
71
|
+
<Field asChild>
|
|
72
|
+
<CustomInput aria-label="Credential" />
|
|
73
|
+
</Field>
|
|
74
|
+
) : null
|
|
75
|
+
}
|
|
76
|
+
</McpAddFormPrimitiveAuthFields>
|
|
77
|
+
<McpAddFormPrimitiveError />
|
|
78
|
+
<McpAddFormPrimitiveSubmit>Submit</McpAddFormPrimitiveSubmit>
|
|
79
|
+
</McpAddFormPrimitiveRoot>,
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
fireEvent.change(screen.getByLabelText("Name"), {
|
|
83
|
+
target: { value: "Docs" },
|
|
84
|
+
});
|
|
85
|
+
fireEvent.change(screen.getByLabelText("URL"), {
|
|
86
|
+
target: { value: "https://example.com/mcp" },
|
|
87
|
+
});
|
|
88
|
+
fireEvent.change(screen.getByLabelText("Auth"), {
|
|
89
|
+
target: { value: authType },
|
|
90
|
+
});
|
|
91
|
+
fireEvent.change(screen.getByLabelText("Credential"), {
|
|
92
|
+
target: { value },
|
|
93
|
+
});
|
|
94
|
+
fireEvent.click(screen.getByRole("button", { name: "Submit" }));
|
|
95
|
+
|
|
96
|
+
await waitFor(() => expect(mocks.addCustomServer).toHaveBeenCalledOnce());
|
|
97
|
+
expect(mocks.addCustomServer).toHaveBeenCalledWith({
|
|
98
|
+
name: "Docs",
|
|
99
|
+
url: "https://example.com/mcp",
|
|
100
|
+
auth,
|
|
101
|
+
});
|
|
102
|
+
},
|
|
103
|
+
);
|
|
104
|
+
|
|
40
105
|
it.each(["throws", "rejects"] as const)(
|
|
41
106
|
"does not turn a successful add into an error when onSubmitted %s",
|
|
42
107
|
async (mode) => {
|