@assistant-ui/react-mcp 0.0.17 → 0.0.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 +0 -1
- package/dist/auth/buildHeaders.d.ts.map +1 -1
- package/dist/auth/createOAuthProvider.d.ts +4 -3
- package/dist/auth/createOAuthProvider.d.ts.map +1 -1
- package/dist/auth/createOAuthProvider.js +2 -1
- package/dist/auth/createOAuthProvider.js.map +1 -1
- package/dist/auth/types.d.ts +2 -2
- package/dist/auth/types.d.ts.map +1 -1
- package/dist/connector.d.ts +0 -1
- package/dist/connector.d.ts.map +1 -1
- package/dist/context/McpConnectorByIndexProvider.d.ts +0 -1
- package/dist/context/McpConnectorByIndexProvider.d.ts.map +1 -1
- package/dist/context/McpCustomServerByIndexProvider.d.ts +0 -1
- package/dist/context/McpCustomServerByIndexProvider.d.ts.map +1 -1
- package/dist/context/McpServerByIdProvider.d.ts +0 -1
- package/dist/context/McpServerByIdProvider.d.ts.map +1 -1
- package/dist/hooks/useMcpOAuthCallback.d.ts +4 -3
- package/dist/hooks/useMcpOAuthCallback.d.ts.map +1 -1
- package/dist/hooks/useMcpOAuthCallback.js +14 -8
- package/dist/hooks/useMcpOAuthCallback.js.map +1 -1
- package/dist/mcp-scope.d.ts +16 -6
- package/dist/mcp-scope.d.ts.map +1 -1
- package/dist/primitives/addForm/McpAddFormAuthFields.d.ts +0 -1
- package/dist/primitives/addForm/McpAddFormAuthFields.d.ts.map +1 -1
- package/dist/primitives/addForm/McpAddFormAuthSelect.d.ts +0 -1
- package/dist/primitives/addForm/McpAddFormAuthSelect.d.ts.map +1 -1
- package/dist/primitives/addForm/McpAddFormAuthSelect.js.map +1 -1
- package/dist/primitives/addForm/McpAddFormCancel.d.ts +0 -1
- package/dist/primitives/addForm/McpAddFormCancel.d.ts.map +1 -1
- package/dist/primitives/addForm/McpAddFormError.d.ts +0 -1
- package/dist/primitives/addForm/McpAddFormError.d.ts.map +1 -1
- package/dist/primitives/addForm/McpAddFormError.js.map +1 -1
- package/dist/primitives/addForm/McpAddFormNameField.d.ts +0 -1
- package/dist/primitives/addForm/McpAddFormNameField.d.ts.map +1 -1
- package/dist/primitives/addForm/McpAddFormRoot.d.ts +0 -1
- package/dist/primitives/addForm/McpAddFormRoot.d.ts.map +1 -1
- package/dist/primitives/addForm/McpAddFormRoot.js.map +1 -1
- package/dist/primitives/addForm/McpAddFormSubmit.d.ts +0 -1
- package/dist/primitives/addForm/McpAddFormSubmit.d.ts.map +1 -1
- package/dist/primitives/addForm/McpAddFormUrlField.d.ts +0 -1
- package/dist/primitives/addForm/McpAddFormUrlField.d.ts.map +1 -1
- package/dist/primitives/addForm/context.d.ts +0 -1
- package/dist/primitives/addForm/context.d.ts.map +1 -1
- package/dist/primitives/addForm.d.ts +0 -2
- package/dist/primitives/manager/McpManagerAddCustomTrigger.d.ts +0 -1
- package/dist/primitives/manager/McpManagerAddCustomTrigger.d.ts.map +1 -1
- package/dist/primitives/manager/McpManagerConnectors.d.ts +0 -1
- package/dist/primitives/manager/McpManagerConnectors.d.ts.map +1 -1
- package/dist/primitives/manager/McpManagerConnectors.js.map +1 -1
- package/dist/primitives/manager/McpManagerCustomServers.d.ts +0 -1
- package/dist/primitives/manager/McpManagerCustomServers.d.ts.map +1 -1
- package/dist/primitives/manager/McpManagerCustomServers.js.map +1 -1
- package/dist/primitives/manager/McpManagerRoot.d.ts +0 -1
- package/dist/primitives/manager/McpManagerRoot.d.ts.map +1 -1
- package/dist/primitives/manager.d.ts +0 -2
- package/dist/primitives/server/McpServerConnectButton.d.ts +0 -1
- package/dist/primitives/server/McpServerConnectButton.d.ts.map +1 -1
- package/dist/primitives/server/McpServerDisconnectButton.d.ts +0 -1
- package/dist/primitives/server/McpServerDisconnectButton.d.ts.map +1 -1
- package/dist/primitives/server/McpServerError.d.ts +0 -1
- package/dist/primitives/server/McpServerError.d.ts.map +1 -1
- package/dist/primitives/server/McpServerError.js.map +1 -1
- package/dist/primitives/server/McpServerIcon.d.ts +4 -3
- package/dist/primitives/server/McpServerIcon.d.ts.map +1 -1
- package/dist/primitives/server/McpServerName.d.ts +0 -1
- package/dist/primitives/server/McpServerName.d.ts.map +1 -1
- package/dist/primitives/server/McpServerName.js.map +1 -1
- package/dist/primitives/server/McpServerOAuthLink.d.ts +4 -3
- package/dist/primitives/server/McpServerOAuthLink.d.ts.map +1 -1
- package/dist/primitives/server/McpServerRemoveButton.d.ts +0 -1
- package/dist/primitives/server/McpServerRemoveButton.d.ts.map +1 -1
- package/dist/primitives/server/McpServerRoot.d.ts +0 -1
- package/dist/primitives/server/McpServerRoot.d.ts.map +1 -1
- package/dist/primitives/server/McpServerStatus.d.ts +0 -1
- package/dist/primitives/server/McpServerStatus.d.ts.map +1 -1
- package/dist/primitives/server/McpServerStatus.js.map +1 -1
- package/dist/primitives/server/McpServerToolName.d.ts +0 -1
- package/dist/primitives/server/McpServerToolName.d.ts.map +1 -1
- package/dist/primitives/server/McpServerToolName.js.map +1 -1
- package/dist/primitives/server/McpServerTools.d.ts +0 -1
- package/dist/primitives/server/McpServerTools.d.ts.map +1 -1
- package/dist/primitives/server/McpServerTools.js.map +1 -1
- package/dist/primitives/server.d.ts +0 -2
- package/dist/resources/McpManagerResource.d.ts +6 -4
- package/dist/resources/McpManagerResource.d.ts.map +1 -1
- package/dist/resources/McpServerResource.d.ts +0 -1
- package/dist/resources/McpServerResource.d.ts.map +1 -1
- package/dist/resources/McpServerResource.js +99 -34
- package/dist/resources/McpServerResource.js.map +1 -1
- package/dist/resources/storage/McpCustomStorage.d.ts +0 -1
- package/dist/resources/storage/McpCustomStorage.d.ts.map +1 -1
- package/dist/resources/storage/McpLocalStorage.d.ts +8 -3
- package/dist/resources/storage/McpLocalStorage.d.ts.map +1 -1
- package/dist/resources/storage/McpLocalStorage.js +66 -3
- package/dist/resources/storage/McpLocalStorage.js.map +1 -1
- package/dist/resources/storage/McpMemoryStorage.d.ts +0 -1
- package/dist/resources/storage/McpMemoryStorage.d.ts.map +1 -1
- package/dist/resources/storage/types.d.ts +0 -1
- package/dist/resources/storage/types.d.ts.map +1 -1
- package/dist/utils/serverId.d.ts.map +1 -1
- package/package.json +9 -9
- package/src/hooks/useMcpOAuthCallback.test.ts +24 -0
- package/src/hooks/useMcpOAuthCallback.tsx +20 -5
- package/src/mcp-scope.ts +2 -0
- package/src/resources/McpServerResource.test.ts +327 -11
- package/src/resources/McpServerResource.ts +124 -30
- package/src/resources/storage/McpLocalStorage.test.ts +240 -0
- package/src/resources/storage/McpLocalStorage.ts +134 -3
|
@@ -46,6 +46,64 @@ const useMcpServerResource = (
|
|
|
46
46
|
|
|
47
47
|
const clientRef = useRef<Client | null>(null);
|
|
48
48
|
const transportRef = useRef<StreamableHTTPClientTransport | null>(null);
|
|
49
|
+
const pendingTransportRef = useRef<StreamableHTTPClientTransport | null>(
|
|
50
|
+
null,
|
|
51
|
+
);
|
|
52
|
+
const transportCloseQueueRef = useRef(Promise.resolve());
|
|
53
|
+
const connectionGenerationRef = useRef(0);
|
|
54
|
+
const pendingDisposalRef = useRef<{ cancelled: boolean } | null>(null);
|
|
55
|
+
const mountedRef = useRef(true);
|
|
56
|
+
|
|
57
|
+
const closeTransportSafely = async (
|
|
58
|
+
transport: StreamableHTTPClientTransport,
|
|
59
|
+
): Promise<void> => {
|
|
60
|
+
try {
|
|
61
|
+
await transport.close();
|
|
62
|
+
} catch {
|
|
63
|
+
// ignore close errors
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const closeQueuedTransports = (
|
|
68
|
+
transports: StreamableHTTPClientTransport[],
|
|
69
|
+
): Promise<void> => {
|
|
70
|
+
const task = transportCloseQueueRef.current.then(async () => {
|
|
71
|
+
await Promise.all(transports.map(closeTransportSafely));
|
|
72
|
+
});
|
|
73
|
+
transportCloseQueueRef.current = task;
|
|
74
|
+
return task;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const closePendingTransport = async () => {
|
|
78
|
+
const transport = pendingTransportRef.current;
|
|
79
|
+
pendingTransportRef.current = null;
|
|
80
|
+
await closeQueuedTransports(transport ? [transport] : []);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const closeTransports = async () => {
|
|
84
|
+
const pendingTransport = pendingTransportRef.current;
|
|
85
|
+
const activeTransport = transportRef.current;
|
|
86
|
+
pendingTransportRef.current = null;
|
|
87
|
+
transportRef.current = null;
|
|
88
|
+
clientRef.current = null;
|
|
89
|
+
|
|
90
|
+
const transports = new Set(
|
|
91
|
+
[pendingTransport, activeTransport].filter(
|
|
92
|
+
(transport): transport is StreamableHTTPClientTransport =>
|
|
93
|
+
transport !== null,
|
|
94
|
+
),
|
|
95
|
+
);
|
|
96
|
+
await closeQueuedTransports([...transports]);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const isCurrentConnection = (generation: number) =>
|
|
100
|
+
mountedRef.current && generation === connectionGenerationRef.current;
|
|
101
|
+
|
|
102
|
+
const createInterruptedAuthError = (cause?: unknown) =>
|
|
103
|
+
new Error(
|
|
104
|
+
`MCP server "${props.id}" authorization was interrupted before completion.`,
|
|
105
|
+
cause === undefined ? undefined : { cause },
|
|
106
|
+
);
|
|
49
107
|
|
|
50
108
|
const withConnectionTimeout = useEffectEvent(
|
|
51
109
|
async <T>(
|
|
@@ -105,7 +163,10 @@ const useMcpServerResource = (
|
|
|
105
163
|
);
|
|
106
164
|
|
|
107
165
|
const finalizeConnect = useEffectEvent(
|
|
108
|
-
async (
|
|
166
|
+
async (
|
|
167
|
+
transport: StreamableHTTPClientTransport,
|
|
168
|
+
generation: number,
|
|
169
|
+
): Promise<boolean> => {
|
|
109
170
|
const client = new Client({
|
|
110
171
|
name: "assistant-ui-mcp",
|
|
111
172
|
version: "0.0.0",
|
|
@@ -120,6 +181,7 @@ const useMcpServerResource = (
|
|
|
120
181
|
"connecting",
|
|
121
182
|
startedAt,
|
|
122
183
|
);
|
|
184
|
+
if (!isCurrentConnection(generation)) return false;
|
|
123
185
|
// Defer ref assignment until listTools() also succeeds — otherwise a
|
|
124
186
|
// post-connect failure leaves stale refs that `callTool()` would
|
|
125
187
|
// happily walk into, producing confusing SDK errors instead of
|
|
@@ -129,6 +191,9 @@ const useMcpServerResource = (
|
|
|
129
191
|
"listing tools",
|
|
130
192
|
startedAt,
|
|
131
193
|
);
|
|
194
|
+
if (!isCurrentConnection(generation)) return false;
|
|
195
|
+
|
|
196
|
+
pendingTransportRef.current = null;
|
|
132
197
|
clientRef.current = client;
|
|
133
198
|
transportRef.current = transport;
|
|
134
199
|
setTools(
|
|
@@ -142,25 +207,16 @@ const useMcpServerResource = (
|
|
|
142
207
|
}),
|
|
143
208
|
);
|
|
144
209
|
setConnectionState("connected");
|
|
210
|
+
return true;
|
|
145
211
|
},
|
|
146
212
|
);
|
|
147
213
|
|
|
148
|
-
const closeTransport = async () => {
|
|
149
|
-
const t = transportRef.current;
|
|
150
|
-
transportRef.current = null;
|
|
151
|
-
clientRef.current = null;
|
|
152
|
-
if (t) {
|
|
153
|
-
try {
|
|
154
|
-
await t.close();
|
|
155
|
-
} catch {
|
|
156
|
-
// ignore close errors
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
};
|
|
160
|
-
|
|
161
214
|
const doConnect = useEffectEvent(async () => {
|
|
215
|
+
const generation = ++connectionGenerationRef.current;
|
|
162
216
|
// Close any prior transport/client so a re-connect doesn't leak.
|
|
163
|
-
await
|
|
217
|
+
await closeTransports();
|
|
218
|
+
if (!isCurrentConnection(generation)) return;
|
|
219
|
+
|
|
164
220
|
setConnectionState("connecting");
|
|
165
221
|
setLastError(null);
|
|
166
222
|
setAuthorizationUrl(null);
|
|
@@ -170,24 +226,29 @@ const useMcpServerResource = (
|
|
|
170
226
|
let transport: StreamableHTTPClientTransport | null = null;
|
|
171
227
|
try {
|
|
172
228
|
transport = await buildTransport();
|
|
229
|
+
if (!isCurrentConnection(generation)) {
|
|
230
|
+
await closeQueuedTransports([transport]);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
pendingTransportRef.current = transport;
|
|
173
234
|
// Don't assign to transportRef until connect succeeds — otherwise a
|
|
174
235
|
// failed `listTools()` leaves an orphaned transport that future
|
|
175
236
|
// doConnect / doDisconnect calls treat as live.
|
|
176
|
-
await finalizeConnect(transport);
|
|
237
|
+
await finalizeConnect(transport, generation);
|
|
177
238
|
} catch (err) {
|
|
239
|
+
if (!isCurrentConnection(generation)) return;
|
|
240
|
+
|
|
178
241
|
if (err instanceof UnauthorizedError) {
|
|
179
242
|
// OAuth: keep the transport alive so completeAuth can call
|
|
180
243
|
// finishAuth on it. Closing it before storing would leave a
|
|
181
244
|
// closed transport on transportRef.
|
|
245
|
+
pendingTransportRef.current = null;
|
|
182
246
|
transportRef.current = transport;
|
|
183
247
|
setConnectionState("authRequired");
|
|
184
248
|
} else {
|
|
185
249
|
if (transport) {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
} catch {
|
|
189
|
-
// ignore close errors
|
|
190
|
-
}
|
|
250
|
+
pendingTransportRef.current = null;
|
|
251
|
+
await closeQueuedTransports([transport]);
|
|
191
252
|
}
|
|
192
253
|
setLastError({
|
|
193
254
|
message: err instanceof Error ? err.message : String(err),
|
|
@@ -198,13 +259,18 @@ const useMcpServerResource = (
|
|
|
198
259
|
});
|
|
199
260
|
|
|
200
261
|
const doDisconnect = useEffectEvent(async () => {
|
|
262
|
+
connectionGenerationRef.current += 1;
|
|
201
263
|
setTools([]);
|
|
202
264
|
setAuthorizationUrl(null);
|
|
203
265
|
setConnectionState("disconnected");
|
|
204
|
-
await
|
|
266
|
+
await closeTransports();
|
|
205
267
|
});
|
|
206
268
|
|
|
207
269
|
const doCompleteAuth = useEffectEvent(async (callbackUrl: string) => {
|
|
270
|
+
const generation = ++connectionGenerationRef.current;
|
|
271
|
+
await closePendingTransport();
|
|
272
|
+
if (!isCurrentConnection(generation)) throw createInterruptedAuthError();
|
|
273
|
+
|
|
208
274
|
setConnectionState("authPending");
|
|
209
275
|
setLastError(null);
|
|
210
276
|
try {
|
|
@@ -214,17 +280,30 @@ const useMcpServerResource = (
|
|
|
214
280
|
let transport = transportRef.current;
|
|
215
281
|
if (!transport) {
|
|
216
282
|
transport = await buildTransport();
|
|
217
|
-
|
|
283
|
+
if (!isCurrentConnection(generation)) {
|
|
284
|
+
await closeQueuedTransports([transport]);
|
|
285
|
+
throw createInterruptedAuthError();
|
|
286
|
+
}
|
|
218
287
|
}
|
|
288
|
+
transportRef.current = null;
|
|
289
|
+
clientRef.current = null;
|
|
290
|
+
pendingTransportRef.current = transport;
|
|
219
291
|
await transport.finishAuth(code);
|
|
292
|
+
if (!isCurrentConnection(generation)) throw createInterruptedAuthError();
|
|
220
293
|
setAuthorizationUrl(null);
|
|
221
|
-
await finalizeConnect(transport);
|
|
294
|
+
const connected = await finalizeConnect(transport, generation);
|
|
295
|
+
if (!connected) throw createInterruptedAuthError();
|
|
222
296
|
} catch (err) {
|
|
223
|
-
|
|
297
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
298
|
+
if (!isCurrentConnection(generation))
|
|
299
|
+
throw createInterruptedAuthError(error);
|
|
300
|
+
|
|
301
|
+
await closeTransports();
|
|
224
302
|
setLastError({
|
|
225
|
-
message:
|
|
303
|
+
message: error.message,
|
|
226
304
|
});
|
|
227
305
|
setConnectionState("error");
|
|
306
|
+
throw error;
|
|
228
307
|
}
|
|
229
308
|
});
|
|
230
309
|
|
|
@@ -253,14 +332,22 @@ const useMcpServerResource = (
|
|
|
253
332
|
|
|
254
333
|
// Auto-connect on mount when usable auth exists.
|
|
255
334
|
useEffect(() => {
|
|
335
|
+
const previousDisposal = pendingDisposalRef.current;
|
|
336
|
+
if (previousDisposal) previousDisposal.cancelled = true;
|
|
337
|
+
const pendingDisposal = { cancelled: false };
|
|
338
|
+
pendingDisposalRef.current = pendingDisposal;
|
|
339
|
+
mountedRef.current = true;
|
|
256
340
|
const signal = { cancelled: false };
|
|
257
341
|
void tryAutoConnect(signal);
|
|
258
342
|
return () => {
|
|
343
|
+
mountedRef.current = false;
|
|
259
344
|
signal.cancelled = true;
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
345
|
+
// Defer disposal so StrictMode can replay setup before closing the transport.
|
|
346
|
+
queueMicrotask(() => {
|
|
347
|
+
if (pendingDisposal.cancelled) return;
|
|
348
|
+
connectionGenerationRef.current += 1;
|
|
349
|
+
void closeTransports();
|
|
350
|
+
});
|
|
264
351
|
};
|
|
265
352
|
}, []);
|
|
266
353
|
|
|
@@ -308,6 +395,13 @@ const useMcpServerResource = (
|
|
|
308
395
|
arguments: args as Record<string, unknown> | undefined,
|
|
309
396
|
});
|
|
310
397
|
},
|
|
398
|
+
listResources: async (params) => {
|
|
399
|
+
const client = clientRef.current;
|
|
400
|
+
if (!client) {
|
|
401
|
+
throw new Error(`MCP server "${props.id}" is not connected`);
|
|
402
|
+
}
|
|
403
|
+
return await client.listResources(params);
|
|
404
|
+
},
|
|
311
405
|
readResource: async (uri) => {
|
|
312
406
|
const client = clientRef.current;
|
|
313
407
|
if (!client) {
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { createTapRoot, useResource } from "@assistant-ui/tap";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
McpLocalStorage,
|
|
6
|
+
normalizeCustomServerRecords,
|
|
7
|
+
normalizePersistedAuthState,
|
|
8
|
+
} from "./McpLocalStorage";
|
|
9
|
+
|
|
10
|
+
const validRecord = {
|
|
11
|
+
id: "docs",
|
|
12
|
+
name: "Docs",
|
|
13
|
+
url: "https://docs.example.com/mcp",
|
|
14
|
+
auth: { type: "none" },
|
|
15
|
+
createdAt: 1,
|
|
16
|
+
} as const;
|
|
17
|
+
|
|
18
|
+
describe("normalizeCustomServerRecords", () => {
|
|
19
|
+
it("returns an empty list when the persisted value is not an array", () => {
|
|
20
|
+
expect(normalizeCustomServerRecords({ bad: true })).toEqual([]);
|
|
21
|
+
expect(normalizeCustomServerRecords(null)).toEqual([]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("filters malformed custom server entries", () => {
|
|
25
|
+
expect(
|
|
26
|
+
normalizeCustomServerRecords([
|
|
27
|
+
validRecord,
|
|
28
|
+
null,
|
|
29
|
+
{ ...validRecord, id: "" },
|
|
30
|
+
{ ...validRecord, id: "docs__search" },
|
|
31
|
+
{ ...validRecord, name: "" },
|
|
32
|
+
{ ...validRecord, url: " " },
|
|
33
|
+
{ ...validRecord, name: 123 },
|
|
34
|
+
{ ...validRecord, url: null },
|
|
35
|
+
{ ...validRecord, createdAt: Number.NaN },
|
|
36
|
+
{ ...validRecord, auth: { type: "bearer", token: "" } },
|
|
37
|
+
{ ...validRecord, auth: { type: "bearer", token: 123 } },
|
|
38
|
+
]),
|
|
39
|
+
).toEqual([validRecord]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("strips malformed connection timeouts without dropping servers", () => {
|
|
43
|
+
expect(
|
|
44
|
+
normalizeCustomServerRecords([
|
|
45
|
+
{ ...validRecord, id: "string-timeout", connectionTimeout: "10000" },
|
|
46
|
+
{ ...validRecord, id: "null-timeout", connectionTimeout: null },
|
|
47
|
+
{ ...validRecord, id: "negative-timeout", connectionTimeout: -1 },
|
|
48
|
+
{ ...validRecord, id: "nan-timeout", connectionTimeout: Number.NaN },
|
|
49
|
+
{
|
|
50
|
+
...validRecord,
|
|
51
|
+
id: "infinite-timeout",
|
|
52
|
+
connectionTimeout: Number.POSITIVE_INFINITY,
|
|
53
|
+
},
|
|
54
|
+
]),
|
|
55
|
+
).toEqual([
|
|
56
|
+
{ ...validRecord, id: "string-timeout" },
|
|
57
|
+
{ ...validRecord, id: "null-timeout" },
|
|
58
|
+
{ ...validRecord, id: "negative-timeout" },
|
|
59
|
+
{ ...validRecord, id: "nan-timeout" },
|
|
60
|
+
{ ...validRecord, id: "infinite-timeout" },
|
|
61
|
+
]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("accepts finite non-negative connection timeouts", () => {
|
|
65
|
+
expect(
|
|
66
|
+
normalizeCustomServerRecords([
|
|
67
|
+
{ ...validRecord, connectionTimeout: 0 },
|
|
68
|
+
{ ...validRecord, connectionTimeout: 10_000 },
|
|
69
|
+
]),
|
|
70
|
+
).toEqual([
|
|
71
|
+
{ ...validRecord, connectionTimeout: 0 },
|
|
72
|
+
{ ...validRecord, connectionTimeout: 10_000 },
|
|
73
|
+
]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("accepts persisted bearer and oauth auth configs", () => {
|
|
77
|
+
expect(
|
|
78
|
+
normalizeCustomServerRecords([
|
|
79
|
+
{
|
|
80
|
+
...validRecord,
|
|
81
|
+
id: "private-docs",
|
|
82
|
+
auth: { type: "bearer", token: "token" },
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
...validRecord,
|
|
86
|
+
id: "oauth-docs",
|
|
87
|
+
auth: {
|
|
88
|
+
type: "oauth",
|
|
89
|
+
scopes: ["docs.read"],
|
|
90
|
+
authorizationEndpoint: "https://docs.example.com/oauth/authorize",
|
|
91
|
+
tokenEndpoint: "https://docs.example.com/oauth/token",
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
]),
|
|
95
|
+
).toHaveLength(2);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("normalizePersistedAuthState", () => {
|
|
100
|
+
it("returns null when the persisted value is not an object", () => {
|
|
101
|
+
expect(normalizePersistedAuthState(null)).toBeNull();
|
|
102
|
+
expect(normalizePersistedAuthState("bad")).toBeNull();
|
|
103
|
+
expect(normalizePersistedAuthState([])).toBeNull();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("drops malformed auth state fields", () => {
|
|
107
|
+
expect(
|
|
108
|
+
normalizePersistedAuthState({
|
|
109
|
+
token: "",
|
|
110
|
+
codeVerifier: 123,
|
|
111
|
+
tokens: {
|
|
112
|
+
access_token: "access-token",
|
|
113
|
+
token_type: "Bearer",
|
|
114
|
+
expires_in: Number.POSITIVE_INFINITY,
|
|
115
|
+
},
|
|
116
|
+
clientInformation: { client_id: "client-id" },
|
|
117
|
+
}),
|
|
118
|
+
).toBeNull();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("keeps valid bearer and OAuth callback state", () => {
|
|
122
|
+
expect(
|
|
123
|
+
normalizePersistedAuthState({
|
|
124
|
+
token: "bearer-token",
|
|
125
|
+
codeVerifier: "pkce-verifier",
|
|
126
|
+
}),
|
|
127
|
+
).toEqual({
|
|
128
|
+
token: "bearer-token",
|
|
129
|
+
codeVerifier: "pkce-verifier",
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("keeps valid OAuth tokens and client information", () => {
|
|
134
|
+
const tokens = {
|
|
135
|
+
access_token: "access-token",
|
|
136
|
+
token_type: "Bearer",
|
|
137
|
+
refresh_token: "refresh-token",
|
|
138
|
+
expires_in: 3600,
|
|
139
|
+
scope: "docs.read",
|
|
140
|
+
};
|
|
141
|
+
const clientInformation = {
|
|
142
|
+
client_id: "client-id",
|
|
143
|
+
client_secret: "client-secret",
|
|
144
|
+
redirect_uris: ["http://localhost/callback"],
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
expect(
|
|
148
|
+
normalizePersistedAuthState({
|
|
149
|
+
tokens,
|
|
150
|
+
clientInformation,
|
|
151
|
+
}),
|
|
152
|
+
).toEqual({
|
|
153
|
+
tokens,
|
|
154
|
+
clientInformation,
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("keeps valid fields when neighboring fields are malformed", () => {
|
|
159
|
+
expect(
|
|
160
|
+
normalizePersistedAuthState({
|
|
161
|
+
token: "bearer-token",
|
|
162
|
+
codeVerifier: 123,
|
|
163
|
+
tokens: { access_token: "access-token" },
|
|
164
|
+
clientInformation: "not-client-info",
|
|
165
|
+
}),
|
|
166
|
+
).toEqual({
|
|
167
|
+
token: "bearer-token",
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const createStorage = (): Storage => {
|
|
173
|
+
const data = new Map<string, string>();
|
|
174
|
+
return {
|
|
175
|
+
get length() {
|
|
176
|
+
return data.size;
|
|
177
|
+
},
|
|
178
|
+
clear: () => {
|
|
179
|
+
data.clear();
|
|
180
|
+
},
|
|
181
|
+
getItem: (key) => data.get(key) ?? null,
|
|
182
|
+
key: (index) => Array.from(data.keys())[index] ?? null,
|
|
183
|
+
removeItem: (key) => {
|
|
184
|
+
data.delete(key);
|
|
185
|
+
},
|
|
186
|
+
setItem: (key, value) => {
|
|
187
|
+
data.set(key, value);
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const loadStorage = (storage: Storage) =>
|
|
193
|
+
createTapRoot(function McpStorageRoot() {
|
|
194
|
+
return useResource(
|
|
195
|
+
McpLocalStorage({
|
|
196
|
+
keyPrefix: "test-mcp",
|
|
197
|
+
storage,
|
|
198
|
+
}),
|
|
199
|
+
);
|
|
200
|
+
}).getValue();
|
|
201
|
+
|
|
202
|
+
describe("McpLocalStorage custom servers", () => {
|
|
203
|
+
it("strips malformed connection timeouts when loading", async () => {
|
|
204
|
+
const storage = createStorage();
|
|
205
|
+
storage.setItem(
|
|
206
|
+
"test-mcp:custom-servers",
|
|
207
|
+
JSON.stringify([
|
|
208
|
+
validRecord,
|
|
209
|
+
{
|
|
210
|
+
...validRecord,
|
|
211
|
+
id: "bad-timeout",
|
|
212
|
+
connectionTimeout: "immediately",
|
|
213
|
+
},
|
|
214
|
+
]),
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
await expect(loadStorage(storage).loadCustomServers()).resolves.toEqual([
|
|
218
|
+
validRecord,
|
|
219
|
+
{ ...validRecord, id: "bad-timeout" },
|
|
220
|
+
]);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe("McpLocalStorage auth state", () => {
|
|
225
|
+
it("normalizes loaded auth state from localStorage", async () => {
|
|
226
|
+
const storage = createStorage();
|
|
227
|
+
storage.setItem(
|
|
228
|
+
"test-mcp:auth:docs",
|
|
229
|
+
JSON.stringify({
|
|
230
|
+
token: "bearer-token",
|
|
231
|
+
codeVerifier: 123,
|
|
232
|
+
tokens: "not-tokens",
|
|
233
|
+
}),
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
await expect(loadStorage(storage).loadAuthState("docs")).resolves.toEqual({
|
|
237
|
+
token: "bearer-token",
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
});
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { resource } from "@assistant-ui/tap";
|
|
2
|
-
import
|
|
2
|
+
import {
|
|
3
|
+
OAuthClientInformationFullSchema,
|
|
4
|
+
OAuthTokensSchema,
|
|
5
|
+
} from "@modelcontextprotocol/sdk/shared/auth.js";
|
|
6
|
+
import type { MCPAuthConfig, MCPCustomServerRecord } from "../../mcp-scope";
|
|
3
7
|
import type { MCPPersistedAuthState } from "../../auth/types";
|
|
8
|
+
import { assertValidServerId } from "../../utils/serverId";
|
|
4
9
|
import type { MCPStorage } from "./types";
|
|
5
10
|
|
|
6
11
|
export type McpLocalStorageOptions = {
|
|
@@ -22,6 +27,132 @@ function resolveStorage(opts: McpLocalStorageOptions): Storage | null {
|
|
|
22
27
|
return null;
|
|
23
28
|
}
|
|
24
29
|
|
|
30
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
31
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
32
|
+
|
|
33
|
+
const isOptionalString = (value: unknown): value is string | undefined =>
|
|
34
|
+
value === undefined || typeof value === "string";
|
|
35
|
+
|
|
36
|
+
const isNonEmptyString = (value: unknown): value is string =>
|
|
37
|
+
typeof value === "string" && value.trim().length > 0;
|
|
38
|
+
|
|
39
|
+
const isOptionalNonEmptyString = (
|
|
40
|
+
value: unknown,
|
|
41
|
+
): value is string | undefined =>
|
|
42
|
+
value === undefined || isNonEmptyString(value);
|
|
43
|
+
|
|
44
|
+
const isOptionalStringArray = (value: unknown): value is string[] | undefined =>
|
|
45
|
+
value === undefined ||
|
|
46
|
+
(Array.isArray(value) && value.every((item) => typeof item === "string"));
|
|
47
|
+
|
|
48
|
+
const isOptionalConnectionTimeout = (
|
|
49
|
+
value: unknown,
|
|
50
|
+
): value is number | undefined =>
|
|
51
|
+
value === undefined ||
|
|
52
|
+
(typeof value === "number" && Number.isFinite(value) && value >= 0);
|
|
53
|
+
|
|
54
|
+
const isValidServerId = (id: string): boolean => {
|
|
55
|
+
try {
|
|
56
|
+
assertValidServerId(id);
|
|
57
|
+
return true;
|
|
58
|
+
} catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const isMCPAuthConfig = (auth: unknown): auth is MCPAuthConfig => {
|
|
64
|
+
if (!isRecord(auth)) return false;
|
|
65
|
+
|
|
66
|
+
switch (auth.type) {
|
|
67
|
+
case "none":
|
|
68
|
+
return true;
|
|
69
|
+
case "bearer":
|
|
70
|
+
return isOptionalNonEmptyString(auth.token);
|
|
71
|
+
case "oauth":
|
|
72
|
+
return (
|
|
73
|
+
isOptionalStringArray(auth.scopes) &&
|
|
74
|
+
isOptionalString(auth.authorizationEndpoint) &&
|
|
75
|
+
isOptionalString(auth.tokenEndpoint) &&
|
|
76
|
+
isOptionalString(auth.registrationEndpoint) &&
|
|
77
|
+
isOptionalString(auth.clientId) &&
|
|
78
|
+
isOptionalString(auth.clientSecret)
|
|
79
|
+
);
|
|
80
|
+
default:
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const isCustomServerRecord = (
|
|
86
|
+
value: unknown,
|
|
87
|
+
): value is MCPCustomServerRecord => {
|
|
88
|
+
if (!isRecord(value)) return false;
|
|
89
|
+
if (typeof value.id !== "string" || !isValidServerId(value.id)) {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
return (
|
|
93
|
+
isNonEmptyString(value.name) &&
|
|
94
|
+
isNonEmptyString(value.url) &&
|
|
95
|
+
Number.isFinite(value.createdAt) &&
|
|
96
|
+
isMCPAuthConfig(value.auth) &&
|
|
97
|
+
isOptionalConnectionTimeout(value.connectionTimeout)
|
|
98
|
+
);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const normalizeCustomServerRecord = (
|
|
102
|
+
value: unknown,
|
|
103
|
+
): MCPCustomServerRecord | null => {
|
|
104
|
+
if (isCustomServerRecord(value)) return value;
|
|
105
|
+
if (!isRecord(value)) return null;
|
|
106
|
+
|
|
107
|
+
const record = { ...value };
|
|
108
|
+
delete record.connectionTimeout;
|
|
109
|
+
return isCustomServerRecord(record) ? record : null;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export const normalizeCustomServerRecords = (
|
|
113
|
+
value: unknown,
|
|
114
|
+
): MCPCustomServerRecord[] => {
|
|
115
|
+
if (!Array.isArray(value)) return [];
|
|
116
|
+
return value.flatMap((item) => {
|
|
117
|
+
const record = normalizeCustomServerRecord(item);
|
|
118
|
+
return record === null ? [] : [record];
|
|
119
|
+
});
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const normalizeOAuthTokens = (
|
|
123
|
+
value: unknown,
|
|
124
|
+
): MCPPersistedAuthState["tokens"] | undefined => {
|
|
125
|
+
const result = OAuthTokensSchema.safeParse(value);
|
|
126
|
+
return result.success ? result.data : undefined;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const normalizeClientInformation = (
|
|
130
|
+
value: unknown,
|
|
131
|
+
): MCPPersistedAuthState["clientInformation"] | undefined => {
|
|
132
|
+
const result = OAuthClientInformationFullSchema.safeParse(value);
|
|
133
|
+
return result.success ? result.data : undefined;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export const normalizePersistedAuthState = (
|
|
137
|
+
value: unknown,
|
|
138
|
+
): MCPPersistedAuthState | null => {
|
|
139
|
+
if (!isRecord(value)) return null;
|
|
140
|
+
|
|
141
|
+
const state: MCPPersistedAuthState = {};
|
|
142
|
+
if (isNonEmptyString(value.token)) state.token = value.token;
|
|
143
|
+
if (isNonEmptyString(value.codeVerifier)) {
|
|
144
|
+
state.codeVerifier = value.codeVerifier;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const tokens = normalizeOAuthTokens(value.tokens);
|
|
148
|
+
if (tokens) state.tokens = tokens;
|
|
149
|
+
|
|
150
|
+
const clientInformation = normalizeClientInformation(value.clientInformation);
|
|
151
|
+
if (clientInformation) state.clientInformation = clientInformation;
|
|
152
|
+
|
|
153
|
+
return Object.keys(state).length > 0 ? state : null;
|
|
154
|
+
};
|
|
155
|
+
|
|
25
156
|
const useMcpLocalStorage = (opts: McpLocalStorageOptions = {}): MCPStorage => {
|
|
26
157
|
const prefix = opts.keyPrefix ?? "aui-mcp";
|
|
27
158
|
const customServersKey = `${prefix}:custom-servers`;
|
|
@@ -59,12 +190,12 @@ const useMcpLocalStorage = (opts: McpLocalStorageOptions = {}): MCPStorage => {
|
|
|
59
190
|
|
|
60
191
|
return {
|
|
61
192
|
loadCustomServers: async () =>
|
|
62
|
-
read<
|
|
193
|
+
normalizeCustomServerRecords(read<unknown>(customServersKey, [])),
|
|
63
194
|
saveCustomServers: async (records) => {
|
|
64
195
|
write(customServersKey, records);
|
|
65
196
|
},
|
|
66
197
|
loadAuthState: async (id) =>
|
|
67
|
-
read<
|
|
198
|
+
normalizePersistedAuthState(read<unknown>(authKey(id), null)),
|
|
68
199
|
saveAuthState: async (id, state) => {
|
|
69
200
|
write(authKey(id), state);
|
|
70
201
|
},
|