@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.
- package/dist/auth/createOAuthProvider.d.ts +7 -1
- package/dist/auth/createOAuthProvider.d.ts.map +1 -1
- package/dist/auth/createOAuthProvider.js +115 -31
- package/dist/auth/createOAuthProvider.js.map +1 -1
- package/dist/auth/types.d.ts +1 -0
- package/dist/auth/types.d.ts.map +1 -1
- package/dist/hooks/useMcpOAuthCallback.d.ts.map +1 -1
- package/dist/hooks/useMcpOAuthCallback.js +5 -6
- package/dist/hooks/useMcpOAuthCallback.js.map +1 -1
- package/dist/primitives/server/McpServerIcon.js.map +1 -1
- package/dist/primitives/server/McpServerOAuthLink.js.map +1 -1
- package/dist/resources/McpManagerResource.d.ts.map +1 -1
- package/dist/resources/McpManagerResource.js +4 -2
- package/dist/resources/McpManagerResource.js.map +1 -1
- package/dist/resources/McpServerResource.d.ts +2 -1
- package/dist/resources/McpServerResource.d.ts.map +1 -1
- package/dist/resources/McpServerResource.js +107 -21
- package/dist/resources/McpServerResource.js.map +1 -1
- package/dist/resources/storage/McpLocalStorage.d.ts +7 -0
- package/dist/resources/storage/McpLocalStorage.d.ts.map +1 -1
- package/dist/resources/storage/McpLocalStorage.js +132 -37
- package/dist/resources/storage/McpLocalStorage.js.map +1 -1
- package/dist/resources/storage/McpMemoryStorage.d.ts.map +1 -1
- package/dist/resources/storage/McpMemoryStorage.js +9 -6
- package/dist/resources/storage/McpMemoryStorage.js.map +1 -1
- package/dist/resources/storage/types.d.ts +12 -0
- package/dist/resources/storage/types.d.ts.map +1 -1
- package/dist/utils/createMcpId.d.ts +9 -0
- package/dist/utils/createMcpId.d.ts.map +1 -0
- package/dist/utils/createMcpId.js +11 -0
- package/dist/utils/createMcpId.js.map +1 -0
- package/dist/utils/invokeMcpCallback.d.ts.map +1 -1
- package/dist/utils/invokeMcpCallback.js +2 -12
- package/dist/utils/invokeMcpCallback.js.map +1 -1
- package/package.json +8 -8
- package/src/auth/createOAuthProvider.test.ts +407 -2
- package/src/auth/createOAuthProvider.ts +171 -40
- package/src/auth/types.ts +1 -0
- package/src/hooks/useMcpOAuthCallback.test.ts +74 -1
- package/src/hooks/useMcpOAuthCallback.tsx +11 -8
- package/src/resources/McpManagerResource.test.ts +128 -0
- package/src/resources/McpManagerResource.ts +4 -5
- package/src/resources/McpServerResource.test.ts +420 -18
- package/src/resources/McpServerResource.ts +148 -27
- package/src/resources/storage/McpLocalStorage.test.ts +71 -1
- package/src/resources/storage/McpLocalStorage.ts +69 -47
- package/src/resources/storage/McpMemoryStorage.test.ts +99 -0
- package/src/resources/storage/McpMemoryStorage.ts +23 -17
- package/src/resources/storage/types.ts +12 -0
- package/src/utils/createMcpId.test.ts +25 -0
- package/src/utils/createMcpId.ts +10 -0
- 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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
|
307
|
+
await clearOAuthProviderAuthState(storage, id);
|
|
309
308
|
setCustomServers((prev) => prev.filter((s) => s.id !== id));
|
|
310
309
|
}
|
|
311
310
|
},
|