@assistant-ui/react-mcp 0.1.18 → 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.
Files changed (148) hide show
  1. package/dist/auth/buildHeaders.d.ts +1 -2
  2. package/dist/auth/buildHeaders.d.ts.map +1 -1
  3. package/dist/auth/createOAuthProvider.d.ts +5 -6
  4. package/dist/auth/createOAuthProvider.d.ts.map +1 -1
  5. package/dist/auth/types.d.ts +1 -2
  6. package/dist/auth/types.d.ts.map +1 -1
  7. package/dist/connector.d.ts +1 -2
  8. package/dist/connector.d.ts.map +1 -1
  9. package/dist/context/McpConnectorByIndexProvider.d.ts +1 -2
  10. package/dist/context/McpConnectorByIndexProvider.d.ts.map +1 -1
  11. package/dist/context/McpCustomServerByIndexProvider.d.ts +1 -2
  12. package/dist/context/McpCustomServerByIndexProvider.d.ts.map +1 -1
  13. package/dist/context/McpServerByIdProvider.d.ts +1 -2
  14. package/dist/context/McpServerByIdProvider.d.ts.map +1 -1
  15. package/dist/hooks/useMcpOAuthCallback.d.ts +5 -6
  16. package/dist/hooks/useMcpOAuthCallback.d.ts.map +1 -1
  17. package/dist/hooks/useMcpOAuthCallback.js +4 -0
  18. package/dist/hooks/useMcpOAuthCallback.js.map +1 -1
  19. package/dist/mcp-scope.d.ts +14 -15
  20. package/dist/mcp-scope.d.ts.map +1 -1
  21. package/dist/primitives/addForm/McpAddFormAuthFields.d.ts +4 -4
  22. package/dist/primitives/addForm/McpAddFormAuthFields.d.ts.map +1 -1
  23. package/dist/primitives/addForm/McpAddFormAuthFields.js +18 -71
  24. package/dist/primitives/addForm/McpAddFormAuthFields.js.map +1 -1
  25. package/dist/primitives/addForm/McpAddFormAuthSelect.d.ts +2 -3
  26. package/dist/primitives/addForm/McpAddFormAuthSelect.d.ts.map +1 -1
  27. package/dist/primitives/addForm/McpAddFormBearerTokenField.d.ts +10 -0
  28. package/dist/primitives/addForm/McpAddFormBearerTokenField.d.ts.map +1 -0
  29. package/dist/primitives/addForm/McpAddFormBearerTokenField.js +50 -0
  30. package/dist/primitives/addForm/McpAddFormBearerTokenField.js.map +1 -0
  31. package/dist/primitives/addForm/McpAddFormCancel.d.ts +2 -3
  32. package/dist/primitives/addForm/McpAddFormCancel.d.ts.map +1 -1
  33. package/dist/primitives/addForm/McpAddFormError.d.ts +2 -3
  34. package/dist/primitives/addForm/McpAddFormError.d.ts.map +1 -1
  35. package/dist/primitives/addForm/McpAddFormNameField.d.ts +2 -3
  36. package/dist/primitives/addForm/McpAddFormNameField.d.ts.map +1 -1
  37. package/dist/primitives/addForm/McpAddFormRoot.d.ts +2 -3
  38. package/dist/primitives/addForm/McpAddFormRoot.d.ts.map +1 -1
  39. package/dist/primitives/addForm/McpAddFormScopesField.d.ts +10 -0
  40. package/dist/primitives/addForm/McpAddFormScopesField.d.ts.map +1 -0
  41. package/dist/primitives/addForm/McpAddFormScopesField.js +38 -0
  42. package/dist/primitives/addForm/McpAddFormScopesField.js.map +1 -0
  43. package/dist/primitives/addForm/McpAddFormSubmit.d.ts +2 -3
  44. package/dist/primitives/addForm/McpAddFormSubmit.d.ts.map +1 -1
  45. package/dist/primitives/addForm/McpAddFormUrlField.d.ts +2 -3
  46. package/dist/primitives/addForm/McpAddFormUrlField.d.ts.map +1 -1
  47. package/dist/primitives/addForm/context.d.ts +7 -8
  48. package/dist/primitives/addForm/context.d.ts.map +1 -1
  49. package/dist/primitives/addForm.d.ts +4 -2
  50. package/dist/primitives/addForm.js +5 -1
  51. package/dist/primitives/addForm.js.map +1 -1
  52. package/dist/primitives/elicitation/McpElicitationPrimitiveAccept.d.ts +2 -3
  53. package/dist/primitives/elicitation/McpElicitationPrimitiveAccept.d.ts.map +1 -1
  54. package/dist/primitives/elicitation/McpElicitationPrimitiveCancel.d.ts +2 -3
  55. package/dist/primitives/elicitation/McpElicitationPrimitiveCancel.d.ts.map +1 -1
  56. package/dist/primitives/elicitation/McpElicitationPrimitiveDecline.d.ts +2 -3
  57. package/dist/primitives/elicitation/McpElicitationPrimitiveDecline.d.ts.map +1 -1
  58. package/dist/primitives/elicitation/McpElicitationPrimitiveError.d.ts +2 -3
  59. package/dist/primitives/elicitation/McpElicitationPrimitiveError.d.ts.map +1 -1
  60. package/dist/primitives/elicitation/McpElicitationPrimitiveFields.d.ts +3 -3
  61. package/dist/primitives/elicitation/McpElicitationPrimitiveFields.d.ts.map +1 -1
  62. package/dist/primitives/elicitation/McpElicitationPrimitiveItems.d.ts +3 -3
  63. package/dist/primitives/elicitation/McpElicitationPrimitiveItems.d.ts.map +1 -1
  64. package/dist/primitives/elicitation/McpElicitationPrimitiveMessage.d.ts +2 -3
  65. package/dist/primitives/elicitation/McpElicitationPrimitiveMessage.d.ts.map +1 -1
  66. package/dist/primitives/elicitation/McpElicitationPrimitiveRoot.d.ts +2 -3
  67. package/dist/primitives/elicitation/McpElicitationPrimitiveRoot.d.ts.map +1 -1
  68. package/dist/primitives/elicitation/context.d.ts +7 -8
  69. package/dist/primitives/elicitation/context.d.ts.map +1 -1
  70. package/dist/primitives/elicitation/initialElicitationDraft.d.ts +1 -2
  71. package/dist/primitives/elicitation/initialElicitationDraft.d.ts.map +1 -1
  72. package/dist/primitives/elicitation/prepareElicitationContent.d.ts +1 -2
  73. package/dist/primitives/elicitation/prepareElicitationContent.d.ts.map +1 -1
  74. package/dist/primitives/elicitation/prepareElicitationContent.js +5 -1
  75. package/dist/primitives/elicitation/prepareElicitationContent.js.map +1 -1
  76. package/dist/primitives/manager/McpManagerAddCustomTrigger.d.ts +2 -3
  77. package/dist/primitives/manager/McpManagerAddCustomTrigger.d.ts.map +1 -1
  78. package/dist/primitives/manager/McpManagerConnectors.d.ts +2 -3
  79. package/dist/primitives/manager/McpManagerConnectors.d.ts.map +1 -1
  80. package/dist/primitives/manager/McpManagerCustomServers.d.ts +2 -3
  81. package/dist/primitives/manager/McpManagerCustomServers.d.ts.map +1 -1
  82. package/dist/primitives/manager/McpManagerRoot.d.ts +2 -3
  83. package/dist/primitives/manager/McpManagerRoot.d.ts.map +1 -1
  84. package/dist/primitives/server/McpServerConnectButton.d.ts +2 -3
  85. package/dist/primitives/server/McpServerConnectButton.d.ts.map +1 -1
  86. package/dist/primitives/server/McpServerDisconnectButton.d.ts +2 -3
  87. package/dist/primitives/server/McpServerDisconnectButton.d.ts.map +1 -1
  88. package/dist/primitives/server/McpServerError.d.ts +2 -3
  89. package/dist/primitives/server/McpServerError.d.ts.map +1 -1
  90. package/dist/primitives/server/McpServerIcon.d.ts +2 -3
  91. package/dist/primitives/server/McpServerIcon.d.ts.map +1 -1
  92. package/dist/primitives/server/McpServerName.d.ts +2 -3
  93. package/dist/primitives/server/McpServerName.d.ts.map +1 -1
  94. package/dist/primitives/server/McpServerOAuthLink.d.ts +2 -3
  95. package/dist/primitives/server/McpServerOAuthLink.d.ts.map +1 -1
  96. package/dist/primitives/server/McpServerRemoveButton.d.ts +2 -3
  97. package/dist/primitives/server/McpServerRemoveButton.d.ts.map +1 -1
  98. package/dist/primitives/server/McpServerRoot.d.ts +2 -3
  99. package/dist/primitives/server/McpServerRoot.d.ts.map +1 -1
  100. package/dist/primitives/server/McpServerStatus.d.ts +2 -3
  101. package/dist/primitives/server/McpServerStatus.d.ts.map +1 -1
  102. package/dist/primitives/server/McpServerToolName.d.ts +2 -3
  103. package/dist/primitives/server/McpServerToolName.d.ts.map +1 -1
  104. package/dist/primitives/server/McpServerTools.d.ts +3 -4
  105. package/dist/primitives/server/McpServerTools.d.ts.map +1 -1
  106. package/dist/resources/McpManagerResource.d.ts +2 -3
  107. package/dist/resources/McpManagerResource.d.ts.map +1 -1
  108. package/dist/resources/McpManagerResource.js +93 -62
  109. package/dist/resources/McpManagerResource.js.map +1 -1
  110. package/dist/resources/McpServerRemovalFence.d.ts +2 -3
  111. package/dist/resources/McpServerRemovalFence.d.ts.map +1 -1
  112. package/dist/resources/McpServerResource.d.ts +3 -4
  113. package/dist/resources/McpServerResource.d.ts.map +1 -1
  114. package/dist/resources/McpServerResource.js +4 -4
  115. package/dist/resources/McpServerResource.js.map +1 -1
  116. package/dist/resources/storage/McpCustomStorage.d.ts +1 -2
  117. package/dist/resources/storage/McpCustomStorage.d.ts.map +1 -1
  118. package/dist/resources/storage/McpLocalStorage.d.ts +4 -5
  119. package/dist/resources/storage/McpLocalStorage.d.ts.map +1 -1
  120. package/dist/resources/storage/McpMemoryStorage.d.ts +1 -2
  121. package/dist/resources/storage/McpMemoryStorage.d.ts.map +1 -1
  122. package/dist/resources/storage/types.d.ts +2 -3
  123. package/dist/resources/storage/types.d.ts.map +1 -1
  124. package/dist/resources/validateElicitationContent.d.ts +1 -2
  125. package/dist/resources/validateElicitationContent.d.ts.map +1 -1
  126. package/dist/utils/createMcpId.d.ts +1 -2
  127. package/dist/utils/createMcpId.d.ts.map +1 -1
  128. package/dist/utils/invokeMcpCallback.d.ts +1 -2
  129. package/dist/utils/invokeMcpCallback.d.ts.map +1 -1
  130. package/dist/utils/serverId.d.ts +2 -3
  131. package/dist/utils/serverId.d.ts.map +1 -1
  132. package/dist/utils/serverUrl.d.ts +3 -4
  133. package/dist/utils/serverUrl.d.ts.map +1 -1
  134. package/package.json +11 -10
  135. package/src/hooks/useMcpOAuthCallback.test.ts +53 -0
  136. package/src/hooks/useMcpOAuthCallback.tsx +12 -0
  137. package/src/index.ts +2 -1
  138. package/src/primitives/addForm/McpAddFormAccessibility.test.tsx +49 -0
  139. package/src/primitives/addForm/McpAddFormAuthFields.tsx +7 -15
  140. package/src/primitives/addForm/McpAddFormBearerTokenField.tsx +46 -0
  141. package/src/primitives/addForm/McpAddFormRoot.test.tsx +65 -0
  142. package/src/primitives/addForm/McpAddFormScopesField.tsx +34 -0
  143. package/src/primitives/addForm.ts +2 -0
  144. package/src/primitives/elicitation/prepareElicitationContent.ts +8 -6
  145. package/src/resources/McpManagerResource.test.ts +85 -0
  146. package/src/resources/McpManagerResource.ts +56 -23
  147. package/src/resources/McpServerResource.test.ts +9 -6
  148. package/src/resources/McpServerResource.ts +6 -5
@@ -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. Defaults to a minimal built-in for bearer / oauth.
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, ids, setField } = useAddForm();
20
+ const { state, ids } = useAddForm();
18
21
 
19
22
  if (children) {
20
23
  const Render = children;
@@ -30,15 +33,8 @@ export const McpAddFormPrimitiveAuthFields: FC<
30
33
  >
31
34
  Bearer token
32
35
  </label>
33
- <input
36
+ <McpAddFormPrimitiveBearerTokenField
34
37
  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
38
  data-mcp-auth-field="bearer-token"
43
39
  />
44
40
  </div>
@@ -51,12 +47,8 @@ export const McpAddFormPrimitiveAuthFields: FC<
51
47
  <label htmlFor={ids.scopes} data-mcp-auth-field-label="oauth-scopes">
52
48
  OAuth scopes
53
49
  </label>
54
- <input
50
+ <McpAddFormPrimitiveScopesField
55
51
  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
52
  data-mcp-auth-field="oauth-scopes"
61
53
  />
62
54
  </div>
@@ -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";
@@ -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) => {
@@ -0,0 +1,34 @@
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 McpAddFormPrimitiveScopesField {
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 McpAddFormPrimitiveScopesField = forwardRef<
18
+ McpAddFormPrimitiveScopesField.Element,
19
+ McpAddFormPrimitiveScopesField.Props
20
+ >((props, ref) => {
21
+ const { state, setField } = useAddForm();
22
+ return (
23
+ <Primitive.input
24
+ type="text"
25
+ placeholder="Scopes (space-separated, optional)"
26
+ {...props}
27
+ ref={ref}
28
+ value={state.scopes}
29
+ onChange={(e) => setField("scopes", e.target.value)}
30
+ />
31
+ );
32
+ });
33
+
34
+ McpAddFormPrimitiveScopesField.displayName = "McpAddFormPrimitive.ScopesField";
@@ -3,6 +3,8 @@ export { McpAddFormPrimitiveNameField as NameField } from "./addForm/McpAddFormN
3
3
  export { McpAddFormPrimitiveUrlField as UrlField } from "./addForm/McpAddFormUrlField";
4
4
  export { McpAddFormPrimitiveAuthSelect as AuthSelect } from "./addForm/McpAddFormAuthSelect";
5
5
  export { McpAddFormPrimitiveAuthFields as AuthFields } from "./addForm/McpAddFormAuthFields";
6
+ export { McpAddFormPrimitiveBearerTokenField as BearerTokenField } from "./addForm/McpAddFormBearerTokenField";
7
+ export { McpAddFormPrimitiveScopesField as ScopesField } from "./addForm/McpAddFormScopesField";
6
8
  export { McpAddFormPrimitiveSubmit as Submit } from "./addForm/McpAddFormSubmit";
7
9
  export { McpAddFormPrimitiveCancel as Cancel } from "./addForm/McpAddFormCancel";
8
10
  export { McpAddFormPrimitiveError as Error } from "./addForm/McpAddFormError";
@@ -72,12 +72,14 @@ export const prepareElicitationContent = (
72
72
  const contentWithBooleanDefaults = {
73
73
  ...candidateContent,
74
74
  ...Object.fromEntries(
75
- missingRequiredBooleans.map(({ property }) => [
76
- property,
77
- typeof properties[property].default === "boolean"
78
- ? properties[property].default
79
- : false,
80
- ]),
75
+ missingRequiredBooleans.map(({ property }) => {
76
+ const schema = properties[property];
77
+ const defaultValue = isRecord(schema) ? schema.default : undefined;
78
+ return [
79
+ property,
80
+ typeof defaultValue === "boolean" ? defaultValue : false,
81
+ ];
82
+ }),
81
83
  ),
82
84
  };
83
85
  const validationErrors = validateElicitationContent(
@@ -92,6 +92,51 @@ describe("McpManagerResource server ids", () => {
92
92
  expect(() => assertUniqueServerIds(["docs", "linear"])).not.toThrow();
93
93
  });
94
94
 
95
+ it("keeps the first persisted custom server when ids are duplicated", async () => {
96
+ const docsServer: MCPCustomServerRecord = {
97
+ id: "docs",
98
+ name: "Docs",
99
+ url: "https://example.com/docs/mcp",
100
+ auth: { type: "none" },
101
+ createdAt: 1,
102
+ };
103
+ const saveCustomServers = vi.fn(async () => {});
104
+ const consoleError = vi
105
+ .spyOn(console, "error")
106
+ .mockImplementation(() => {});
107
+ const root = mount(
108
+ [],
109
+ McpCustomStorage({
110
+ loadCustomServers: vi.fn(async () => [
111
+ docsServer,
112
+ { ...docsServer, name: "Duplicate Docs", createdAt: 2 },
113
+ ]),
114
+ saveCustomServers,
115
+ loadAuthState: vi.fn(async () => null),
116
+ saveAuthState: vi.fn(async () => {}),
117
+ clearAuthState: vi.fn(async () => {}),
118
+ }),
119
+ );
120
+
121
+ try {
122
+ await vi.waitFor(() =>
123
+ expect(root.getValue().getState().isHydrated).toBe(true),
124
+ );
125
+ expect(root.getValue().getState().customServers).toHaveLength(1);
126
+ expect(root.getValue().getState().customServers[0]).toMatchObject({
127
+ id: "docs",
128
+ name: "Docs",
129
+ });
130
+ expect(saveCustomServers).not.toHaveBeenCalled();
131
+ expect(consoleError).toHaveBeenCalledWith(
132
+ '[assistant-ui/react-mcp] ignored duplicate custom server id "docs" loaded from storage',
133
+ );
134
+ } finally {
135
+ consoleError.mockRestore();
136
+ root.unmount();
137
+ }
138
+ });
139
+
95
140
  it("passes connector cache configuration to its client", async () => {
96
141
  mocks.Client.mockClear();
97
142
  const root = mount([
@@ -374,6 +419,46 @@ describe("McpManagerResource storage failures", () => {
374
419
  });
375
420
 
376
421
  describe("McpManagerResource storage ordering", () => {
422
+ it("preserves a removal made before custom server hydration finishes", async () => {
423
+ const docsServer: MCPCustomServerRecord = {
424
+ id: "docs",
425
+ name: "Docs",
426
+ url: "https://example.com/docs/mcp",
427
+ auth: { type: "none" },
428
+ createdAt: 1,
429
+ };
430
+ let resolveLoad!: (records: MCPCustomServerRecord[]) => void;
431
+ const load = new Promise<MCPCustomServerRecord[]>((resolve) => {
432
+ resolveLoad = resolve;
433
+ });
434
+ const saveCustomServers = vi.fn(async () => {});
435
+ const root = mount(
436
+ [],
437
+ McpCustomStorage({
438
+ loadCustomServers: vi.fn(() => load),
439
+ saveCustomServers,
440
+ loadAuthState: vi.fn(async () => null),
441
+ saveAuthState: vi.fn(async () => {}),
442
+ clearAuthState: vi.fn(async () => {}),
443
+ }),
444
+ );
445
+
446
+ try {
447
+ await root.getValue().removeServer("docs");
448
+ resolveLoad([docsServer]);
449
+
450
+ await vi.waitFor(() =>
451
+ expect(root.getValue().getState().isHydrated).toBe(true),
452
+ );
453
+ expect(root.getValue().getState().customServers).toHaveLength(0);
454
+ await vi.waitFor(() =>
455
+ expect(saveCustomServers).toHaveBeenCalledWith([]),
456
+ );
457
+ } finally {
458
+ root.unmount();
459
+ }
460
+ });
461
+
377
462
  it("persists custom server updates in invocation order", async () => {
378
463
  let resolveFirstSave: (() => void) | undefined;
379
464
  const firstSave = new Promise<void>((resolve) => {
@@ -116,6 +116,20 @@ const holdCustomServerPersistence = (
116
116
  return release;
117
117
  };
118
118
 
119
+ const deduplicateCustomServers = (records: MCPCustomServerRecord[]) => {
120
+ const seen = new Set<string>();
121
+ return records.filter((record) => {
122
+ if (seen.has(record.id)) {
123
+ console.error(
124
+ `[assistant-ui/react-mcp] ignored duplicate custom server id "${record.id}" loaded from storage`,
125
+ );
126
+ return false;
127
+ }
128
+ seen.add(record.id);
129
+ return true;
130
+ });
131
+ };
132
+
119
133
  type McpCustomServersResourceProps = {
120
134
  storage: MCPStorage;
121
135
  scopeKey: string;
@@ -137,6 +151,7 @@ const useMcpCustomServersResource = ({
137
151
  "pending",
138
152
  );
139
153
  const hasPendingMutationRef = useRef(false);
154
+ const [removedBeforeHydration] = useState(() => new Set<string>());
140
155
  const reportedBlockedPersistenceRef = useRef(false);
141
156
 
142
157
  const hydrate = useEffectEvent(async (signal: { cancelled: boolean }) => {
@@ -151,7 +166,8 @@ const useMcpCustomServersResource = ({
151
166
 
152
167
  let records: Awaited<ReturnType<typeof storage.loadCustomServers>>;
153
168
  try {
154
- records = await storage.loadCustomServers();
169
+ const loadedRecords = await storage.loadCustomServers();
170
+ records = deduplicateCustomServers(loadedRecords);
155
171
  } catch (error) {
156
172
  if (!signal.cancelled) {
157
173
  reportCustomStorageFailure("load", error);
@@ -164,16 +180,21 @@ const useMcpCustomServersResource = ({
164
180
  }
165
181
  return;
166
182
  }
167
-
168
183
  // Merge rather than replace so any addCustomServer calls that
169
184
  // happened before hydration resolved aren't silently overwritten.
170
185
  // Persisted order wins; pre-hydration locals append.
171
186
  const hadPendingMutation = hasPendingMutationRef.current;
187
+ const hydratedRecords = records.filter(
188
+ (record) => !removedBeforeHydration.has(record.id),
189
+ );
172
190
  const mergedRecords = (() => {
173
191
  const prev = customServersRef.current;
174
- if (prev.length === 0) return records;
175
- const persistedIds = new Set(records.map((r) => r.id));
176
- return [...records, ...prev.filter((r) => !persistedIds.has(r.id))];
192
+ if (prev.length === 0) return hydratedRecords;
193
+ const persistedIds = new Set(hydratedRecords.map((r) => r.id));
194
+ return [
195
+ ...hydratedRecords,
196
+ ...prev.filter((r) => !persistedIds.has(r.id)),
197
+ ];
177
198
  })();
178
199
  customServersRef.current = mergedRecords;
179
200
  hydrationStateRef.current = "succeeded";
@@ -232,7 +253,22 @@ const useMcpCustomServersResource = ({
232
253
  [persistenceQueues, scopeKey, storage],
233
254
  );
234
255
 
235
- return { customServers, isHydrated, updateCustomServers };
256
+ const removeCustomServer = useCallback(
257
+ (id: string) => {
258
+ if (hydrationStateRef.current === "pending") {
259
+ removedBeforeHydration.add(id);
260
+ }
261
+ updateCustomServers((prev) => prev.filter((record) => record.id !== id));
262
+ },
263
+ [removedBeforeHydration, updateCustomServers],
264
+ );
265
+
266
+ return {
267
+ customServers,
268
+ isHydrated,
269
+ updateCustomServers,
270
+ removeCustomServer,
271
+ };
236
272
  };
237
273
 
238
274
  const McpCustomServersResource = resource(useMcpCustomServersResource);
@@ -252,16 +288,17 @@ const useMcpManagerResource = (
252
288
  );
253
289
  const storageScopeKey =
254
290
  storage.scopeId === undefined ? "unscoped" : `scoped:${storage.scopeId}`;
255
- const { customServers, isHydrated, updateCustomServers } = useResource(
256
- withKey(
257
- storageScopeKey,
258
- McpCustomServersResource({
259
- storage,
260
- scopeKey: storageScopeKey,
261
- persistenceQueues,
262
- }),
263
- ),
264
- );
291
+ const { customServers, isHydrated, updateCustomServers, removeCustomServer } =
292
+ useResource(
293
+ withKey(
294
+ storageScopeKey,
295
+ McpCustomServersResource({
296
+ storage,
297
+ scopeKey: storageScopeKey,
298
+ persistenceQueues,
299
+ }),
300
+ ),
301
+ );
265
302
 
266
303
  const serverElements = useMemo(() => {
267
304
  assertUniqueServerIds([
@@ -313,9 +350,7 @@ const useMcpManagerResource = (
313
350
  ? { elicitation: s.elicitation }
314
351
  : {}),
315
352
  onRemove: async () => {
316
- updateCustomServers((prev) =>
317
- prev.filter((record) => record.id !== s.id),
318
- );
353
+ removeCustomServer(s.id);
319
354
  },
320
355
  },
321
356
  () =>
@@ -332,7 +367,7 @@ const useMcpManagerResource = (
332
367
  redirectUri,
333
368
  autoConnect,
334
369
  connectionTimeout,
335
- updateCustomServers,
370
+ removeCustomServer,
336
371
  persistenceQueues,
337
372
  storageScopeKey,
338
373
  ]);
@@ -450,9 +485,7 @@ const useMcpManagerResource = (
450
485
  );
451
486
  try {
452
487
  await clearOAuthProviderAuthState(storage, id);
453
- updateCustomServers((prev) =>
454
- prev.filter((record) => record.id !== id),
455
- );
488
+ removeCustomServer(id);
456
489
  } catch (error) {
457
490
  releasePersistence();
458
491
  throw error;
@@ -113,8 +113,8 @@ const requestElicitation = (
113
113
  client: any,
114
114
  message: string,
115
115
  requestedSchema: unknown,
116
- context: { signal: AbortSignal } = {
117
- signal: new AbortController().signal,
116
+ context: { mcpReq: { signal: AbortSignal } } = {
117
+ mcpReq: { signal: new AbortController().signal },
118
118
  },
119
119
  ) => {
120
120
  const handler = client.requestHandlers.get("elicitation/create");
@@ -156,7 +156,6 @@ const mount = (
156
156
  cache?: { readonly defaultTtlMs?: number } | undefined;
157
157
  elicitation?: boolean | undefined;
158
158
  kind?: "connector" | "custom" | undefined;
159
- storage?: MCPStorage | undefined;
160
159
  onRemove?: (() => Promise<void>) | undefined;
161
160
  },
162
161
  onMount?: (server: ClientOutput<"mcpServer">) => void,
@@ -292,7 +291,9 @@ describe("McpServerResource automatic authentication", () => {
292
291
  });
293
292
 
294
293
  try {
295
- await waitFor(() => storage.loadAuthState.mock.calls.length > 0);
294
+ await waitFor(
295
+ () => vi.mocked(storage.loadAuthState).mock.calls.length > 0,
296
+ );
296
297
  await flushMacrotask();
297
298
 
298
299
  expect(root.getValue().getState()).toMatchObject({
@@ -338,7 +339,9 @@ describe("McpServerResource automatic authentication", () => {
338
339
  });
339
340
 
340
341
  try {
341
- await waitFor(() => storage.loadAuthState.mock.calls.length > 0);
342
+ await waitFor(
343
+ () => vi.mocked(storage.loadAuthState).mock.calls.length > 0,
344
+ );
342
345
  await flushMacrotask();
343
346
 
344
347
  expect(root.getValue().getState()).toMatchObject({
@@ -1474,7 +1477,7 @@ describe("McpServerResource elicitation", () => {
1474
1477
  type: "object",
1475
1478
  properties: {},
1476
1479
  },
1477
- { signal: controller.signal },
1480
+ { mcpReq: { signal: controller.signal } },
1478
1481
  );
1479
1482
  await waitForResourceUpdate(
1480
1483
  () => root.getValue().getState().pendingElicitations.length === 1,
@@ -380,7 +380,7 @@ const useMcpServerResourceInstance = (
380
380
  };
381
381
  elicitationResolversRef.current.set(id, {
382
382
  resolve,
383
- signal: context.signal,
383
+ signal: context.mcpReq.signal,
384
384
  onAbort,
385
385
  requestedSchema,
386
386
  });
@@ -395,10 +395,10 @@ const useMcpServerResourceInstance = (
395
395
  ]);
396
396
  const entry = elicitationResolversRef.current.get(id);
397
397
  if (entry) {
398
- if (context.signal.aborted) {
398
+ if (context.mcpReq.signal.aborted) {
399
399
  entry.onAbort();
400
400
  } else {
401
- context.signal.addEventListener("abort", entry.onAbort, {
401
+ context.mcpReq.signal.addEventListener("abort", entry.onAbort, {
402
402
  once: true,
403
403
  });
404
404
  }
@@ -712,7 +712,7 @@ const useMcpServerResourceInstance = (
712
712
  ): readonly { property: string; message: string }[] | undefined => {
713
713
  if (response.action === "accept") {
714
714
  const entry = elicitationResolversRef.current.get(id);
715
- if (!entry) return;
715
+ if (!entry) return undefined;
716
716
 
717
717
  if (
718
718
  typeof response.content !== "object" ||
@@ -752,10 +752,11 @@ const useMcpServerResourceInstance = (
752
752
  content: response.content as ElicitResult["content"],
753
753
  };
754
754
  resolvePendingElicitation(id, result);
755
- return;
755
+ return undefined;
756
756
  }
757
757
 
758
758
  resolvePendingElicitation(id, { action: response.action });
759
+ return undefined;
759
760
  },
760
761
  };
761
762
  };