@assistant-ui/react-mcp 0.1.21 → 0.1.22

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 (50) hide show
  1. package/dist/auth/createOAuthProvider.d.ts.map +1 -1
  2. package/dist/auth/createOAuthProvider.js +7 -1
  3. package/dist/auth/createOAuthProvider.js.map +1 -1
  4. package/dist/primitives/addForm/McpAddFormRoot.d.ts.map +1 -1
  5. package/dist/primitives/addForm/McpAddFormRoot.js +14 -5
  6. package/dist/primitives/addForm/McpAddFormRoot.js.map +1 -1
  7. package/dist/resources/McpCustomServerPersistence.d.ts +17 -0
  8. package/dist/resources/McpCustomServerPersistence.d.ts.map +1 -0
  9. package/dist/resources/McpCustomServerPersistence.js +192 -0
  10. package/dist/resources/McpCustomServerPersistence.js.map +1 -0
  11. package/dist/resources/McpElicitationLifecycle.d.ts +12 -0
  12. package/dist/resources/McpElicitationLifecycle.d.ts.map +1 -0
  13. package/dist/resources/McpElicitationLifecycle.js +144 -0
  14. package/dist/resources/McpElicitationLifecycle.js.map +1 -0
  15. package/dist/resources/McpManagerResource.d.ts.map +1 -1
  16. package/dist/resources/McpManagerResource.js +28 -205
  17. package/dist/resources/McpManagerResource.js.map +1 -1
  18. package/dist/resources/McpServerResource.d.ts.map +1 -1
  19. package/dist/resources/McpServerResource.js +24 -101
  20. package/dist/resources/McpServerResource.js.map +1 -1
  21. package/dist/resources/storage/McpLocalStorage.d.ts +1 -4
  22. package/dist/resources/storage/McpLocalStorage.d.ts.map +1 -1
  23. package/dist/resources/storage/McpLocalStorage.js +1 -100
  24. package/dist/resources/storage/McpLocalStorage.js.map +1 -1
  25. package/dist/resources/storage/McpStoredDataNormalization.d.ts +5 -0
  26. package/dist/resources/storage/McpStoredDataNormalization.d.ts.map +1 -0
  27. package/dist/resources/storage/McpStoredDataNormalization.js +95 -0
  28. package/dist/resources/storage/McpStoredDataNormalization.js.map +1 -0
  29. package/dist/utils/serverUrl.d.ts +1 -0
  30. package/dist/utils/serverUrl.d.ts.map +1 -1
  31. package/dist/utils/serverUrl.js +15 -1
  32. package/dist/utils/serverUrl.js.map +1 -1
  33. package/package.json +10 -9
  34. package/src/auth/createOAuthProvider.test.ts +30 -1
  35. package/src/auth/createOAuthProvider.ts +12 -4
  36. package/src/primitives/addForm/McpAddFormRoot.test.tsx +66 -0
  37. package/src/primitives/addForm/McpAddFormRoot.tsx +11 -1
  38. package/src/resources/McpCustomServerPersistence.test.ts +56 -0
  39. package/src/resources/McpCustomServerPersistence.ts +233 -0
  40. package/src/resources/McpElicitationLifecycle.test.ts +83 -0
  41. package/src/resources/McpElicitationLifecycle.ts +156 -0
  42. package/src/resources/McpManagerResource.test.ts +36 -0
  43. package/src/resources/McpManagerResource.ts +25 -248
  44. package/src/resources/McpServerResource.test.ts +123 -10
  45. package/src/resources/McpServerResource.ts +29 -149
  46. package/src/resources/storage/McpLocalStorage.ts +9 -209
  47. package/src/resources/storage/McpStoredDataNormalization.test.ts +24 -0
  48. package/src/resources/storage/McpStoredDataNormalization.ts +201 -0
  49. package/src/utils/serverUrl.test.ts +30 -0
  50. package/src/utils/serverUrl.ts +26 -0
@@ -1,11 +1,4 @@
1
- import {
2
- useState,
3
- useEffect,
4
- useMemo,
5
- useEffectEvent,
6
- useRef,
7
- useCallback,
8
- } from "react";
1
+ import { useState, useMemo } from "react";
9
2
  import { useResource, resource, withKey } from "@assistant-ui/tap";
10
3
  import {
11
4
  useClientLookup,
@@ -19,9 +12,14 @@ import { createMcpId } from "../utils/createMcpId";
19
12
  import { clearOAuthProviderAuthState } from "../auth/createOAuthProvider";
20
13
  import type { Tool } from "assistant-stream";
21
14
  import { McpServerResource } from "./McpServerResource";
15
+ import {
16
+ McpCustomServersResource,
17
+ holdCustomServerPersistence,
18
+ type CustomServerPersistenceQueues,
19
+ } from "./McpCustomServerPersistence";
22
20
  import { withMcpServerRemovalFence } from "./McpServerRemovalFence";
23
21
  import { McpLocalStorage } from "./storage/McpLocalStorage";
24
- import type { MCPStorage, MCPStorageElement } from "./storage/types";
22
+ import type { MCPStorageElement } from "./storage/types";
25
23
  import { assertUniqueServerIds } from "../utils/serverId";
26
24
  import type {
27
25
  MCPAuthConfig,
@@ -50,229 +48,6 @@ function defaultRedirectUri(): string {
50
48
  // array each render (which would invalidate the serverElements memo below).
51
49
  const NO_CONNECTORS: MCPConnector[] = [];
52
50
 
53
- const reportCustomStorageFailure = (
54
- operation: "load" | "save",
55
- error: unknown,
56
- ) => {
57
- console.error(
58
- `[assistant-ui/react-mcp] failed to ${operation} custom servers:`,
59
- error,
60
- );
61
- };
62
-
63
- const reportBlockedCustomServerPersistence = () => {
64
- console.error(
65
- "[assistant-ui/react-mcp] custom server changes remain in memory because loading the persisted list failed; remount the manager to retry",
66
- );
67
- };
68
-
69
- const persistCustomServers = async (
70
- storage: MCPStorage,
71
- records: MCPCustomServerRecord[],
72
- ) => {
73
- try {
74
- await storage.saveCustomServers(records);
75
- } catch (error) {
76
- reportCustomStorageFailure("save", error);
77
- }
78
- };
79
-
80
- type CustomServerPersistenceQueues = Map<string, Promise<void>>;
81
-
82
- const enqueueCustomServerTask = (
83
- persistenceQueues: CustomServerPersistenceQueues,
84
- scopeKey: string,
85
- task: () => Promise<void>,
86
- ) => {
87
- const previous = persistenceQueues.get(scopeKey);
88
- const next = (previous ?? Promise.resolve()).then(task);
89
- persistenceQueues.set(scopeKey, next);
90
- void next.then(() => {
91
- if (persistenceQueues.get(scopeKey) === next) {
92
- persistenceQueues.delete(scopeKey);
93
- }
94
- });
95
- };
96
-
97
- const enqueueCustomServerPersistence = (
98
- persistenceQueues: CustomServerPersistenceQueues,
99
- scopeKey: string,
100
- storage: MCPStorage,
101
- records: MCPCustomServerRecord[],
102
- ) =>
103
- enqueueCustomServerTask(persistenceQueues, scopeKey, () =>
104
- persistCustomServers(storage, records),
105
- );
106
-
107
- const holdCustomServerPersistence = (
108
- persistenceQueues: CustomServerPersistenceQueues,
109
- scopeKey: string,
110
- ) => {
111
- let release = () => {};
112
- const gate = new Promise<void>((resolve) => {
113
- release = resolve;
114
- });
115
- enqueueCustomServerTask(persistenceQueues, scopeKey, () => gate);
116
- return release;
117
- };
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
-
133
- type McpCustomServersResourceProps = {
134
- storage: MCPStorage;
135
- scopeKey: string;
136
- persistenceQueues: CustomServerPersistenceQueues;
137
- };
138
-
139
- const useMcpCustomServersResource = ({
140
- storage,
141
- scopeKey,
142
- persistenceQueues,
143
- }: McpCustomServersResourceProps) => {
144
- const [customServers, setCustomServers] = useState<MCPCustomServerRecord[]>(
145
- [],
146
- );
147
- const [isHydrated, setIsHydrated] = useState(false);
148
-
149
- const customServersRef = useRef<MCPCustomServerRecord[]>([]);
150
- const hydrationStateRef = useRef<"pending" | "succeeded" | "failed">(
151
- "pending",
152
- );
153
- const hasPendingMutationRef = useRef(false);
154
- const [removedBeforeHydration] = useState(() => new Set<string>());
155
- const reportedBlockedPersistenceRef = useRef(false);
156
-
157
- const hydrate = useEffectEvent(async (signal: { cancelled: boolean }) => {
158
- // A revisited scope must not read behind writes still queued against it.
159
- while (true) {
160
- const pendingPersistence = persistenceQueues.get(scopeKey);
161
- if (!pendingPersistence) break;
162
- await pendingPersistence;
163
- if (signal.cancelled) return;
164
- if (persistenceQueues.get(scopeKey) === pendingPersistence) break;
165
- }
166
-
167
- let records: Awaited<ReturnType<typeof storage.loadCustomServers>>;
168
- try {
169
- const loadedRecords = await storage.loadCustomServers();
170
- records = deduplicateCustomServers(loadedRecords);
171
- } catch (error) {
172
- if (!signal.cancelled) {
173
- reportCustomStorageFailure("load", error);
174
- hydrationStateRef.current = "failed";
175
- if (hasPendingMutationRef.current) {
176
- reportBlockedCustomServerPersistence();
177
- reportedBlockedPersistenceRef.current = true;
178
- }
179
- setIsHydrated(true);
180
- }
181
- return;
182
- }
183
- // Merge rather than replace so any addCustomServer calls that
184
- // happened before hydration resolved aren't silently overwritten.
185
- // Persisted order wins; pre-hydration locals append.
186
- const hadPendingMutation = hasPendingMutationRef.current;
187
- const hydratedRecords = records.filter(
188
- (record) => !removedBeforeHydration.has(record.id),
189
- );
190
- const mergedRecords = (() => {
191
- const prev = customServersRef.current;
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
- ];
198
- })();
199
- customServersRef.current = mergedRecords;
200
- hydrationStateRef.current = "succeeded";
201
- hasPendingMutationRef.current = false;
202
- if (hadPendingMutation) {
203
- enqueueCustomServerPersistence(
204
- persistenceQueues,
205
- scopeKey,
206
- storage,
207
- mergedRecords,
208
- );
209
- }
210
- if (signal.cancelled) return;
211
- setCustomServers(mergedRecords);
212
- setIsHydrated(true);
213
- });
214
-
215
- useEffect(() => {
216
- const signal = { cancelled: false };
217
- // Hydration reads persisted records asynchronously; there is no earlier
218
- // point than mount at which to start it.
219
- // eslint-disable-next-line react-hooks/set-state-in-effect
220
- void hydrate(signal);
221
- return () => {
222
- signal.cancelled = true;
223
- };
224
- }, []);
225
-
226
- const updateCustomServers = useCallback(
227
- (
228
- updater: (records: MCPCustomServerRecord[]) => MCPCustomServerRecord[],
229
- ) => {
230
- const next = updater(customServersRef.current);
231
- customServersRef.current = next;
232
- setCustomServers(next);
233
-
234
- if (hydrationStateRef.current === "succeeded") {
235
- enqueueCustomServerPersistence(
236
- persistenceQueues,
237
- scopeKey,
238
- storage,
239
- next,
240
- );
241
- return;
242
- }
243
-
244
- hasPendingMutationRef.current = true;
245
- if (
246
- hydrationStateRef.current === "failed" &&
247
- !reportedBlockedPersistenceRef.current
248
- ) {
249
- reportBlockedCustomServerPersistence();
250
- reportedBlockedPersistenceRef.current = true;
251
- }
252
- },
253
- [persistenceQueues, scopeKey, storage],
254
- );
255
-
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
- };
272
- };
273
-
274
- const McpCustomServersResource = resource(useMcpCustomServersResource);
275
-
276
51
  const useMcpManagerResource = (
277
52
  props: McpManagerResourceProps,
278
53
  ): ClientOutput<"mcp"> => {
@@ -472,26 +247,28 @@ const useMcpManagerResource = (
472
247
  `Cannot remove connector "${id}" — connectors are app-defined and not removable. Use a custom server id instead.`,
473
248
  );
474
249
  }
475
- // Delegate to McpServerResource.remove() which disconnects,
476
- // clears auth state, and unregisters from customServers in one
477
- // place. Fallback to manual cleanup if the lookup is empty
478
- // (server already gone).
250
+ let server;
479
251
  try {
480
- await lookup.get({ key: id }).remove();
252
+ server = lookup.get({ key: id });
481
253
  } catch {
482
- const releasePersistence = holdCustomServerPersistence(
483
- persistenceQueues,
484
- storageScopeKey,
485
- );
486
- try {
487
- await clearOAuthProviderAuthState(storage, id);
488
- removeCustomServer(id);
489
- } catch (error) {
490
- releasePersistence();
491
- throw error;
492
- }
254
+ server = undefined;
255
+ }
256
+ if (server) {
257
+ await server.remove();
258
+ return;
259
+ }
260
+ const releasePersistence = holdCustomServerPersistence(
261
+ persistenceQueues,
262
+ storageScopeKey,
263
+ );
264
+ try {
265
+ await clearOAuthProviderAuthState(storage, id);
266
+ removeCustomServer(id);
267
+ } catch (error) {
493
268
  releasePersistence();
269
+ throw error;
494
270
  }
271
+ releasePersistence();
495
272
  },
496
273
  };
497
274
  };
@@ -157,6 +157,7 @@ const mount = (
157
157
  cache?: { readonly defaultTtlMs?: number } | undefined;
158
158
  elicitation?: boolean | undefined;
159
159
  kind?: "connector" | "custom" | undefined;
160
+ url?: string | undefined;
160
161
  onRemove?: (() => Promise<void>) | undefined;
161
162
  },
162
163
  onMount?: (server: ClientOutput<"mcpServer">) => void,
@@ -170,7 +171,7 @@ const mount = (
170
171
  id: "docs",
171
172
  kind: props?.kind ?? "connector",
172
173
  name: "Docs",
173
- url: "https://example.com/mcp",
174
+ url: props?.url ?? "https://example.com/mcp",
174
175
  auth: props?.auth ?? { type: "none" },
175
176
  storage: props?.storage ?? createStorage(),
176
177
  redirectUri: "https://example.com/callback",
@@ -205,6 +206,75 @@ const getOAuthProvider = (index: number) => {
205
206
  describe("McpServerResource automatic authentication", () => {
206
207
  beforeEach(resetMocks);
207
208
 
209
+ it.each(["bearer", "oauth"] as const)(
210
+ "rejects %s authentication over remote HTTP",
211
+ async (type) => {
212
+ const root = mount({
213
+ auth: type === "bearer" ? { type, token: "secret" } : { type },
214
+ url: "http://mcp.example.com/mcp",
215
+ });
216
+
217
+ try {
218
+ await root.getValue().connect();
219
+ await waitForResourceUpdate(
220
+ () => root.getValue().getState().connectionState === "error",
221
+ );
222
+
223
+ expect(root.getValue().getState()).toMatchObject({
224
+ connectionState: "error",
225
+ lastError: {
226
+ message:
227
+ 'Authenticated MCP server "docs" must use HTTPS or loopback HTTP.',
228
+ },
229
+ });
230
+ expect(mocks.StreamableHTTPClientTransport).not.toHaveBeenCalled();
231
+ } finally {
232
+ root.unmount();
233
+ }
234
+ },
235
+ );
236
+
237
+ it.each([
238
+ "http://localhost:3000/mcp",
239
+ "http://dev.localhost:3000/mcp",
240
+ "http://127.0.0.1:3000/mcp",
241
+ "http://127.0.0.2:3000/mcp",
242
+ "http://[::1]:3000/mcp",
243
+ "http://localhost.:3000/mcp",
244
+ "http://dev.localhost.:3000/mcp",
245
+ "http://[::ffff:127.0.0.1]:3000/mcp",
246
+ ])("allows bearer authentication over loopback HTTP at %s", async (url) => {
247
+ const root = mount({
248
+ auth: { type: "bearer", token: "secret" },
249
+ url,
250
+ autoConnect: true,
251
+ });
252
+
253
+ try {
254
+ await waitFor(() => mocks.transports.length > 0);
255
+ expect(mocks.StreamableHTTPClientTransport).toHaveBeenCalledWith(
256
+ new URL(url),
257
+ { requestInit: { headers: { Authorization: "Bearer secret" } } },
258
+ );
259
+ } finally {
260
+ root.unmount();
261
+ }
262
+ });
263
+
264
+ it("allows unauthenticated MCP servers over remote HTTP", async () => {
265
+ const url = "http://mcp.example.com/mcp";
266
+ const root = mount({ auth: { type: "none" }, url, autoConnect: true });
267
+
268
+ try {
269
+ await waitFor(() => mocks.transports.length > 0);
270
+ expect(mocks.StreamableHTTPClientTransport).toHaveBeenCalledWith(
271
+ new URL(url),
272
+ );
273
+ } finally {
274
+ root.unmount();
275
+ }
276
+ });
277
+
208
278
  it("does not auto-connect with authentication from another server URL", async () => {
209
279
  const storage = createStorage();
210
280
  vi.mocked(storage.loadAuthState).mockResolvedValue({
@@ -642,26 +712,69 @@ describe("McpServerResource connectionTimeout", () => {
642
712
  describe("McpServerResource connection lifecycle", () => {
643
713
  beforeEach(resetMocks);
644
714
 
645
- it("publishes authorization URLs from the current connection", async () => {
715
+ it.each([
716
+ "https://auth.example.com/current",
717
+ "http://localhost:3000/current",
718
+ ])(
719
+ "publishes authorization URLs from the current connection at %s",
720
+ async (url) => {
721
+ const root = mount({ auth: { type: "oauth" } });
722
+
723
+ try {
724
+ await root.getValue().connect();
725
+ await getOAuthProvider(0).redirectToAuthorization(new URL(url));
726
+ await waitForResourceUpdate(
727
+ () => root.getValue().getState().authorizationUrl !== null,
728
+ );
729
+
730
+ expect(root.getValue().getState().authorizationUrl).toBe(url);
731
+ } finally {
732
+ root.unmount();
733
+ }
734
+ },
735
+ );
736
+
737
+ it("rejects remote HTTP OAuth authorization URLs from the current connection", async () => {
646
738
  const root = mount({ auth: { type: "oauth" } });
647
739
 
648
740
  try {
649
741
  await root.getValue().connect();
650
- await getOAuthProvider(0).redirectToAuthorization(
651
- new URL("https://auth.example.com/current"),
652
- );
653
- await waitForResourceUpdate(
654
- () => root.getValue().getState().authorizationUrl !== null,
655
- );
656
742
 
657
- expect(root.getValue().getState().authorizationUrl).toBe(
658
- "https://auth.example.com/current",
743
+ await expect(
744
+ getOAuthProvider(0).redirectToAuthorization(
745
+ new URL("http://auth.example.com/authorize"),
746
+ ),
747
+ ).rejects.toThrow(
748
+ "MCP OAuth authorization URL must use HTTPS or loopback HTTP.",
659
749
  );
750
+ expect(root.getValue().getState().authorizationUrl).toBeNull();
660
751
  } finally {
661
752
  root.unmount();
662
753
  }
663
754
  });
664
755
 
756
+ it.each(["javascript:", "data:", "file:"])(
757
+ "rejects %s OAuth authorization URLs from the current connection",
758
+ async (protocol) => {
759
+ const root = mount({ auth: { type: "oauth" } });
760
+
761
+ try {
762
+ await root.getValue().connect();
763
+
764
+ await expect(
765
+ getOAuthProvider(0).redirectToAuthorization(
766
+ new URL(`${protocol}unsafe`),
767
+ ),
768
+ ).rejects.toThrow(
769
+ `Unsupported MCP OAuth authorization URL protocol: ${protocol}`,
770
+ );
771
+ expect(root.getValue().getState().authorizationUrl).toBeNull();
772
+ } finally {
773
+ root.unmount();
774
+ }
775
+ },
776
+ );
777
+
665
778
  it("ignores authorization URLs after disconnect", async () => {
666
779
  const root = mount({ auth: { type: "oauth" } });
667
780