@assistant-ui/react-mcp 0.1.14 → 0.1.16

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 (52) hide show
  1. package/dist/auth/createOAuthProvider.d.ts +7 -1
  2. package/dist/auth/createOAuthProvider.d.ts.map +1 -1
  3. package/dist/auth/createOAuthProvider.js +115 -31
  4. package/dist/auth/createOAuthProvider.js.map +1 -1
  5. package/dist/auth/types.d.ts +1 -0
  6. package/dist/auth/types.d.ts.map +1 -1
  7. package/dist/hooks/useMcpOAuthCallback.d.ts.map +1 -1
  8. package/dist/hooks/useMcpOAuthCallback.js +5 -6
  9. package/dist/hooks/useMcpOAuthCallback.js.map +1 -1
  10. package/dist/primitives/server/McpServerIcon.js.map +1 -1
  11. package/dist/primitives/server/McpServerOAuthLink.js.map +1 -1
  12. package/dist/resources/McpManagerResource.d.ts.map +1 -1
  13. package/dist/resources/McpManagerResource.js +4 -2
  14. package/dist/resources/McpManagerResource.js.map +1 -1
  15. package/dist/resources/McpServerResource.d.ts +2 -1
  16. package/dist/resources/McpServerResource.d.ts.map +1 -1
  17. package/dist/resources/McpServerResource.js +107 -21
  18. package/dist/resources/McpServerResource.js.map +1 -1
  19. package/dist/resources/storage/McpLocalStorage.d.ts +7 -0
  20. package/dist/resources/storage/McpLocalStorage.d.ts.map +1 -1
  21. package/dist/resources/storage/McpLocalStorage.js +132 -37
  22. package/dist/resources/storage/McpLocalStorage.js.map +1 -1
  23. package/dist/resources/storage/McpMemoryStorage.d.ts.map +1 -1
  24. package/dist/resources/storage/McpMemoryStorage.js +9 -6
  25. package/dist/resources/storage/McpMemoryStorage.js.map +1 -1
  26. package/dist/resources/storage/types.d.ts +12 -0
  27. package/dist/resources/storage/types.d.ts.map +1 -1
  28. package/dist/utils/createMcpId.d.ts +9 -0
  29. package/dist/utils/createMcpId.d.ts.map +1 -0
  30. package/dist/utils/createMcpId.js +11 -0
  31. package/dist/utils/createMcpId.js.map +1 -0
  32. package/dist/utils/invokeMcpCallback.d.ts.map +1 -1
  33. package/dist/utils/invokeMcpCallback.js +2 -12
  34. package/dist/utils/invokeMcpCallback.js.map +1 -1
  35. package/package.json +8 -8
  36. package/src/auth/createOAuthProvider.test.ts +407 -2
  37. package/src/auth/createOAuthProvider.ts +171 -40
  38. package/src/auth/types.ts +1 -0
  39. package/src/hooks/useMcpOAuthCallback.test.ts +74 -1
  40. package/src/hooks/useMcpOAuthCallback.tsx +11 -8
  41. package/src/resources/McpManagerResource.test.ts +128 -0
  42. package/src/resources/McpManagerResource.ts +4 -5
  43. package/src/resources/McpServerResource.test.ts +420 -18
  44. package/src/resources/McpServerResource.ts +148 -27
  45. package/src/resources/storage/McpLocalStorage.test.ts +71 -1
  46. package/src/resources/storage/McpLocalStorage.ts +69 -47
  47. package/src/resources/storage/McpMemoryStorage.test.ts +99 -0
  48. package/src/resources/storage/McpMemoryStorage.ts +23 -17
  49. package/src/resources/storage/types.ts +12 -0
  50. package/src/utils/createMcpId.test.ts +25 -0
  51. package/src/utils/createMcpId.ts +10 -0
  52. package/src/utils/invokeMcpCallback.ts +3 -21
package/src/auth/types.ts CHANGED
@@ -8,6 +8,7 @@ export type MCPPersistedAuthState = {
8
8
  tokens?: OAuthTokens;
9
9
  clientInformation?: OAuthClientInformationFull;
10
10
  codeVerifier?: string;
11
+ state?: string;
11
12
  discoveryState?: OAuthDiscoveryState;
12
13
  /** Bearer token (entered at add-form time). */
13
14
  token?: string;
@@ -1,6 +1,12 @@
1
1
  // @vitest-environment jsdom
2
2
 
3
- import { renderHook, waitFor } from "@testing-library/react";
3
+ import {
4
+ createElement,
5
+ Suspense,
6
+ startTransition,
7
+ type PropsWithChildren,
8
+ } from "react";
9
+ import { act, renderHook, waitFor } from "@testing-library/react";
4
10
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
11
 
6
12
  const mocks = vi.hoisted(() => {
@@ -98,6 +104,73 @@ describe("useMcpOAuthCallback", () => {
98
104
  });
99
105
  },
100
106
  );
107
+
108
+ it("lets the server validate error callback parameters", async () => {
109
+ const authError = new Error("authorization response issuer mismatch");
110
+ const url = `${callbackUrl}&error=access_denied&error_description=untrusted`;
111
+ mocks.completeAuth.mockRejectedValueOnce(authError);
112
+
113
+ const { result } = renderHook(() => useMcpOAuthCallback({ url }));
114
+
115
+ await waitFor(() => expect(result.current.status).toBe("error"));
116
+ expect(mocks.completeAuth).toHaveBeenCalledWith(url);
117
+ expect(result.current.error).toMatchObject({
118
+ message:
119
+ 'MCP OAuth callback for server "docs" failed: authorization response issuer mismatch',
120
+ cause: authError,
121
+ });
122
+ });
123
+
124
+ it("keeps callbacks scoped to committed renders", async () => {
125
+ let resolveAuth!: () => void;
126
+ mocks.completeAuth.mockReturnValueOnce(
127
+ new Promise<void>((resolve) => {
128
+ resolveAuth = resolve;
129
+ }),
130
+ );
131
+ const onCompleteA = vi.fn();
132
+ const onCompleteB = vi.fn();
133
+ const interruptedRender = vi.fn();
134
+ const pending = new Promise<never>(() => {});
135
+ let blocked = false;
136
+ const Blocker = () => {
137
+ if (blocked) {
138
+ interruptedRender();
139
+ throw pending;
140
+ }
141
+ return null;
142
+ };
143
+ const Wrapper = ({ children }: PropsWithChildren) =>
144
+ createElement(
145
+ Suspense,
146
+ { fallback: null },
147
+ children,
148
+ createElement(Blocker),
149
+ );
150
+
151
+ const { rerender } = renderHook(
152
+ ({ onComplete }) => useMcpOAuthCallback({ url: callbackUrl, onComplete }),
153
+ {
154
+ initialProps: { onComplete: onCompleteA },
155
+ wrapper: Wrapper,
156
+ },
157
+ );
158
+ await waitFor(() => expect(mocks.completeAuth).toHaveBeenCalledOnce());
159
+
160
+ act(() => {
161
+ blocked = true;
162
+ startTransition(() => rerender({ onComplete: onCompleteB }));
163
+ });
164
+ expect(interruptedRender).toHaveBeenCalled();
165
+
166
+ await act(async () => {
167
+ resolveAuth();
168
+ await Promise.resolve();
169
+ });
170
+
171
+ expect(onCompleteB).not.toHaveBeenCalled();
172
+ expect(onCompleteA).toHaveBeenCalledWith("docs");
173
+ });
101
174
  });
102
175
 
103
176
  describe("createMcpOAuthCallbackError", () => {
@@ -1,4 +1,11 @@
1
- import { type FC, type ReactNode, useEffect, useRef, useState } from "react";
1
+ import {
2
+ type FC,
3
+ type ReactNode,
4
+ useEffect,
5
+ useInsertionEffect,
6
+ useRef,
7
+ useState,
8
+ } from "react";
2
9
  import { useAui } from "@assistant-ui/store";
3
10
  import { decodeServerIdFromState } from "../auth/createOAuthProvider";
4
11
  import { invokeMcpCallback } from "../utils/invokeMcpCallback";
@@ -44,7 +51,9 @@ export function useMcpOAuthCallback(
44
51
  // single-use OAuth code is double-redeemed and the second attempt 4xxs.
45
52
  const startedRef = useRef<string | null>(null);
46
53
  const optsRef = useRef(opts);
47
- optsRef.current = opts;
54
+ useInsertionEffect(() => {
55
+ optsRef.current = opts;
56
+ });
48
57
 
49
58
  useEffect(() => {
50
59
  const url =
@@ -59,12 +68,6 @@ export function useMcpOAuthCallback(
59
68
  const parsed = new URL(url);
60
69
  const state = parsed.searchParams.get("state");
61
70
  if (state) serverId = decodeServerIdFromState(state);
62
- const error = parsed.searchParams.get("error");
63
- if (error) {
64
- throw new Error(
65
- parsed.searchParams.get("error_description") ?? error,
66
- );
67
- }
68
71
  if (!state) throw new Error('missing "state" parameter');
69
72
  if (!serverId) {
70
73
  throw new Error("state was not created by assistant-ui MCP");
@@ -147,6 +147,134 @@ describe("McpManagerResource server ids", () => {
147
147
  root.unmount();
148
148
  }
149
149
  });
150
+
151
+ it("replaces a connected transport when connector settings change", async () => {
152
+ mocks.StreamableHTTPClientTransport.mockClear();
153
+ let updateConnector = (_connector: MCPConnector) => {};
154
+ const DynamicManager = resource(function useDynamicManager() {
155
+ const [currentConnector, setCurrentConnector] = useState(
156
+ connector("docs"),
157
+ );
158
+ updateConnector = setCurrentConnector;
159
+
160
+ return useResource(
161
+ McpManagerResource({
162
+ connectors: [currentConnector],
163
+ storage: McpMemoryStorage(),
164
+ }),
165
+ );
166
+ });
167
+ const root = createTapRoot(function Root() {
168
+ return useResource(DynamicManager());
169
+ });
170
+ let resolveFirstClose = () => {};
171
+
172
+ try {
173
+ await vi.waitFor(() =>
174
+ expect(mocks.StreamableHTTPClientTransport).toHaveBeenCalledOnce(),
175
+ );
176
+ const firstTransport = mocks.StreamableHTTPClientTransport.mock
177
+ .instances[0] as { close: ReturnType<typeof vi.fn> };
178
+ firstTransport.close.mockImplementation(
179
+ () =>
180
+ new Promise<void>((resolve) => {
181
+ resolveFirstClose = resolve;
182
+ }),
183
+ );
184
+
185
+ updateConnector(
186
+ defineConnector({
187
+ id: "docs",
188
+ name: "Docs",
189
+ url: "https://other.example.com/docs/mcp",
190
+ auth: { type: "none" },
191
+ }),
192
+ );
193
+
194
+ await vi.waitFor(() =>
195
+ expect(firstTransport.close).toHaveBeenCalledOnce(),
196
+ );
197
+ expect(mocks.StreamableHTTPClientTransport).toHaveBeenCalledOnce();
198
+
199
+ resolveFirstClose();
200
+ await vi.waitFor(() =>
201
+ expect(mocks.StreamableHTTPClientTransport).toHaveBeenCalledTimes(2),
202
+ );
203
+
204
+ expect(mocks.StreamableHTTPClientTransport).toHaveBeenLastCalledWith(
205
+ new URL("https://other.example.com/docs/mcp"),
206
+ );
207
+ } finally {
208
+ resolveFirstClose();
209
+ root.unmount();
210
+ }
211
+ });
212
+
213
+ it("keeps a connection across equivalent and cosmetic connector updates", async () => {
214
+ mocks.StreamableHTTPClientTransport.mockClear();
215
+ let rerenderEquivalent = () => {};
216
+ let updatePresentation = () => {};
217
+ const DynamicManager = resource(function useDynamicManager() {
218
+ const [, setVersion] = useState(0);
219
+ const [presentation, setPresentation] = useState({
220
+ name: "Docs",
221
+ icon: "docs.svg",
222
+ });
223
+ rerenderEquivalent = () => setVersion((version) => version + 1);
224
+ updatePresentation = () =>
225
+ setPresentation({ name: "Documentation", icon: "book.svg" });
226
+
227
+ return useResource(
228
+ McpManagerResource({
229
+ connectors: [
230
+ defineConnector({
231
+ id: "docs",
232
+ name: presentation.name,
233
+ icon: presentation.icon,
234
+ url: "https://example.com/docs/mcp",
235
+ auth: { type: "none" },
236
+ }),
237
+ ],
238
+ storage: McpCustomStorage({
239
+ loadCustomServers: vi.fn(async () => []),
240
+ saveCustomServers: vi.fn(async () => {}),
241
+ loadAuthState: vi.fn(async () => null),
242
+ saveAuthState: vi.fn(async () => {}),
243
+ clearAuthState: vi.fn(async () => {}),
244
+ }),
245
+ autoConnect: false,
246
+ }),
247
+ );
248
+ });
249
+ const root = createTapRoot(function Root() {
250
+ return useResource(DynamicManager());
251
+ });
252
+
253
+ try {
254
+ await root.getValue().connector({ index: 0 }).connect();
255
+ const transport = mocks.StreamableHTTPClientTransport.mock
256
+ .instances[0] as { close: ReturnType<typeof vi.fn> };
257
+
258
+ rerenderEquivalent();
259
+ await new Promise((resolve) => setTimeout(resolve, 0));
260
+ expect(mocks.StreamableHTTPClientTransport).toHaveBeenCalledOnce();
261
+ expect(transport.close).not.toHaveBeenCalled();
262
+
263
+ updatePresentation();
264
+ await vi.waitFor(() =>
265
+ expect(
266
+ root.getValue().connector({ index: 0 }).getState(),
267
+ ).toMatchObject({
268
+ name: "Documentation",
269
+ icon: "book.svg",
270
+ }),
271
+ );
272
+ expect(mocks.StreamableHTTPClientTransport).toHaveBeenCalledOnce();
273
+ expect(transport.close).not.toHaveBeenCalled();
274
+ } finally {
275
+ root.unmount();
276
+ }
277
+ });
150
278
  });
151
279
 
152
280
  describe("McpManagerResource storage failures", () => {
@@ -8,6 +8,8 @@ import {
8
8
  } from "@assistant-ui/store";
9
9
  import { useAssistantScopeEffect } from "@assistant-ui/store/client";
10
10
  import { ModelContext } from "@assistant-ui/core/store";
11
+ import { createMcpId } from "../utils/createMcpId";
12
+ import { clearOAuthProviderAuthState } from "../auth/createOAuthProvider";
11
13
  import type { Tool } from "assistant-stream";
12
14
  import { McpServerResource } from "./McpServerResource";
13
15
  import { McpLocalStorage } from "./storage/McpLocalStorage";
@@ -275,10 +277,7 @@ const useMcpManagerResource = (
275
277
  elicitation,
276
278
  }) => {
277
279
  const record: MCPCustomServerRecord = {
278
- id:
279
- typeof crypto !== "undefined" && "randomUUID" in crypto
280
- ? crypto.randomUUID()
281
- : `mcp-${Date.now()}-${Math.random().toString(36).slice(2)}`,
280
+ id: createMcpId(),
282
281
  name,
283
282
  url,
284
283
  auth: auth as MCPAuthConfig,
@@ -305,7 +304,7 @@ const useMcpManagerResource = (
305
304
  try {
306
305
  await lookup.get({ key: id }).remove();
307
306
  } catch {
308
- await storage.clearAuthState(id);
307
+ await clearOAuthProviderAuthState(storage, id);
309
308
  setCustomServers((prev) => prev.filter((s) => s.id !== id));
310
309
  }
311
310
  },