@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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useState, useRef, useEffect, useMemo, useEffectEvent } from "react";
|
|
2
|
-
import { resource } from "@assistant-ui/tap";
|
|
2
|
+
import { resource, useResource, withKey } from "@assistant-ui/tap";
|
|
3
3
|
import type { ClientOutput } from "@assistant-ui/store";
|
|
4
4
|
import {
|
|
5
5
|
Client,
|
|
@@ -10,7 +10,10 @@ import {
|
|
|
10
10
|
type ElicitResult,
|
|
11
11
|
type StreamableHTTPClientTransportOptions,
|
|
12
12
|
} from "@modelcontextprotocol/client";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
clearOAuthProviderAuthState,
|
|
15
|
+
createOAuthProvider,
|
|
16
|
+
} from "../auth/createOAuthProvider";
|
|
14
17
|
import { buildHeaders } from "../auth/buildHeaders";
|
|
15
18
|
import { assertValidServerId } from "../utils/serverId";
|
|
16
19
|
import { validateElicitationContent } from "./validateElicitationContent";
|
|
@@ -24,6 +27,7 @@ import type {
|
|
|
24
27
|
MCPServerState,
|
|
25
28
|
MCPToolInfo,
|
|
26
29
|
} from "../mcp-scope";
|
|
30
|
+
import { createMcpId } from "../utils/createMcpId";
|
|
27
31
|
|
|
28
32
|
export type McpServerResourceProps = {
|
|
29
33
|
id: string;
|
|
@@ -41,8 +45,50 @@ export type McpServerResourceProps = {
|
|
|
41
45
|
onRemove: () => Promise<void>;
|
|
42
46
|
};
|
|
43
47
|
|
|
44
|
-
|
|
48
|
+
type McpServerResourceInstanceProps = McpServerResourceProps & {
|
|
49
|
+
transportCloseQueueRef: { current: Promise<void> };
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const getConnectionDependencies = (
|
|
45
53
|
props: McpServerResourceProps,
|
|
54
|
+
): readonly unknown[] => {
|
|
55
|
+
const auth = props.auth;
|
|
56
|
+
const authDependencies =
|
|
57
|
+
auth.type === "bearer"
|
|
58
|
+
? [auth.type, auth.token, props.storage.scopeId]
|
|
59
|
+
: auth.type === "oauth"
|
|
60
|
+
? [
|
|
61
|
+
auth.type,
|
|
62
|
+
auth.scopes?.length,
|
|
63
|
+
...(auth.scopes ?? []),
|
|
64
|
+
auth.authorizationEndpoint,
|
|
65
|
+
auth.tokenEndpoint,
|
|
66
|
+
auth.registrationEndpoint,
|
|
67
|
+
auth.clientId,
|
|
68
|
+
auth.clientSecret,
|
|
69
|
+
props.storage.scopeId,
|
|
70
|
+
]
|
|
71
|
+
: [auth.type];
|
|
72
|
+
|
|
73
|
+
return [
|
|
74
|
+
props.id,
|
|
75
|
+
props.url,
|
|
76
|
+
...authDependencies,
|
|
77
|
+
props.redirectUri,
|
|
78
|
+
props.cache?.defaultTtlMs,
|
|
79
|
+
props.elicitation !== false,
|
|
80
|
+
];
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const areConnectionDependenciesEqual = (
|
|
84
|
+
left: readonly unknown[],
|
|
85
|
+
right: readonly unknown[],
|
|
86
|
+
) =>
|
|
87
|
+
left.length === right.length &&
|
|
88
|
+
left.every((value, index) => Object.is(value, right[index]));
|
|
89
|
+
|
|
90
|
+
const useMcpServerResourceInstance = (
|
|
91
|
+
props: McpServerResourceInstanceProps,
|
|
46
92
|
): ClientOutput<"mcpServer"> => {
|
|
47
93
|
assertValidServerId(props.id);
|
|
48
94
|
const [connectionState, setConnectionState] =
|
|
@@ -59,8 +105,12 @@ const useMcpServerResource = (
|
|
|
59
105
|
const pendingTransportRef = useRef<StreamableHTTPClientTransport | null>(
|
|
60
106
|
null,
|
|
61
107
|
);
|
|
62
|
-
const transportCloseQueueRef = useRef(Promise.resolve());
|
|
63
108
|
const connectionGenerationRef = useRef(0);
|
|
109
|
+
const pendingAuthValidationRef = useRef<{
|
|
110
|
+
count: number;
|
|
111
|
+
promise: Promise<void>;
|
|
112
|
+
resolve: () => void;
|
|
113
|
+
} | null>(null);
|
|
64
114
|
const elicitationResolversRef = useRef(
|
|
65
115
|
new Map<
|
|
66
116
|
string,
|
|
@@ -88,10 +138,10 @@ const useMcpServerResource = (
|
|
|
88
138
|
const closeQueuedTransports = (
|
|
89
139
|
transports: StreamableHTTPClientTransport[],
|
|
90
140
|
): Promise<void> => {
|
|
91
|
-
const task = transportCloseQueueRef.current.then(async () => {
|
|
141
|
+
const task = props.transportCloseQueueRef.current.then(async () => {
|
|
92
142
|
await Promise.all(transports.map(closeTransportSafely));
|
|
93
143
|
});
|
|
94
|
-
transportCloseQueueRef.current = task;
|
|
144
|
+
props.transportCloseQueueRef.current = task;
|
|
95
145
|
return task;
|
|
96
146
|
};
|
|
97
147
|
|
|
@@ -132,7 +182,7 @@ const useMcpServerResource = (
|
|
|
132
182
|
}
|
|
133
183
|
};
|
|
134
184
|
|
|
135
|
-
const
|
|
185
|
+
const detachTransports = () => {
|
|
136
186
|
cancelPendingElicitations();
|
|
137
187
|
const pendingTransport = pendingTransportRef.current;
|
|
138
188
|
const activeTransport = transportRef.current;
|
|
@@ -140,13 +190,18 @@ const useMcpServerResource = (
|
|
|
140
190
|
transportRef.current = null;
|
|
141
191
|
clientRef.current = null;
|
|
142
192
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
(
|
|
146
|
-
transport
|
|
193
|
+
return [
|
|
194
|
+
...new Set(
|
|
195
|
+
[pendingTransport, activeTransport].filter(
|
|
196
|
+
(transport): transport is StreamableHTTPClientTransport =>
|
|
197
|
+
transport !== null,
|
|
198
|
+
),
|
|
147
199
|
),
|
|
148
|
-
|
|
149
|
-
|
|
200
|
+
];
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const closeTransports = async () => {
|
|
204
|
+
await closeQueuedTransports(detachTransports());
|
|
150
205
|
};
|
|
151
206
|
|
|
152
207
|
const isCurrentConnection = (generation: number) =>
|
|
@@ -305,10 +360,7 @@ const useMcpServerResource = (
|
|
|
305
360
|
}
|
|
306
361
|
const { message, requestedSchema } = request.params;
|
|
307
362
|
|
|
308
|
-
const id =
|
|
309
|
-
typeof crypto !== "undefined" && "randomUUID" in crypto
|
|
310
|
-
? crypto.randomUUID()
|
|
311
|
-
: `mcp-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
363
|
+
const id = createMcpId();
|
|
312
364
|
const promise = new Promise<ElicitResult>((resolve) => {
|
|
313
365
|
const onAbort = () => {
|
|
314
366
|
resolvePendingElicitation(id, { action: "cancel" });
|
|
@@ -421,6 +473,45 @@ const useMcpServerResource = (
|
|
|
421
473
|
});
|
|
422
474
|
|
|
423
475
|
const doCompleteAuth = useEffectEvent(async (callbackUrl: string) => {
|
|
476
|
+
const validationGeneration = connectionGenerationRef.current;
|
|
477
|
+
const url = new URL(callbackUrl);
|
|
478
|
+
const state = url.searchParams.get("state");
|
|
479
|
+
if (!state) throw new Error('missing "state" parameter');
|
|
480
|
+
let pendingAuthValidation = pendingAuthValidationRef.current;
|
|
481
|
+
if (!pendingAuthValidation) {
|
|
482
|
+
let resolve!: () => void;
|
|
483
|
+
const promise = new Promise<void>((resolvePromise) => {
|
|
484
|
+
resolve = resolvePromise;
|
|
485
|
+
});
|
|
486
|
+
pendingAuthValidation = { count: 0, promise, resolve };
|
|
487
|
+
pendingAuthValidationRef.current = pendingAuthValidation;
|
|
488
|
+
}
|
|
489
|
+
pendingAuthValidation.count += 1;
|
|
490
|
+
try {
|
|
491
|
+
const persisted = await props.storage.loadAuthState(props.id);
|
|
492
|
+
if (!isCurrentConnection(validationGeneration)) {
|
|
493
|
+
throw createInterruptedAuthError();
|
|
494
|
+
}
|
|
495
|
+
if (!persisted?.state) {
|
|
496
|
+
throw new Error(
|
|
497
|
+
"no pending OAuth authorization request for this server",
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
if (persisted.state !== state) {
|
|
501
|
+
throw new Error("OAuth state does not match the authorization request");
|
|
502
|
+
}
|
|
503
|
+
if (!url.searchParams.get("code") && !url.searchParams.get("error")) {
|
|
504
|
+
throw new Error("missing authorization code in callback URL");
|
|
505
|
+
}
|
|
506
|
+
} finally {
|
|
507
|
+
pendingAuthValidation.count -= 1;
|
|
508
|
+
if (pendingAuthValidation.count === 0) {
|
|
509
|
+
pendingAuthValidationRef.current = null;
|
|
510
|
+
pendingAuthValidation.resolve();
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Claim the generation before a waiting auto-connect can resume.
|
|
424
515
|
const generation = ++connectionGenerationRef.current;
|
|
425
516
|
cancelPendingElicitations();
|
|
426
517
|
await closePendingTransport();
|
|
@@ -429,9 +520,6 @@ const useMcpServerResource = (
|
|
|
429
520
|
setConnectionState("authPending");
|
|
430
521
|
setLastError(null);
|
|
431
522
|
try {
|
|
432
|
-
const url = new URL(callbackUrl);
|
|
433
|
-
const code = url.searchParams.get("code");
|
|
434
|
-
if (!code) throw new Error("missing authorization code in callback URL");
|
|
435
523
|
let transport = transportRef.current;
|
|
436
524
|
if (!transport) {
|
|
437
525
|
transport = await buildTransport();
|
|
@@ -443,7 +531,7 @@ const useMcpServerResource = (
|
|
|
443
531
|
transportRef.current = null;
|
|
444
532
|
clientRef.current = null;
|
|
445
533
|
pendingTransportRef.current = transport;
|
|
446
|
-
await transport.finishAuth(
|
|
534
|
+
await transport.finishAuth(url.searchParams);
|
|
447
535
|
if (!isCurrentConnection(generation)) throw createInterruptedAuthError();
|
|
448
536
|
setAuthorizationUrl(null);
|
|
449
537
|
const connected = await finalizeConnect(transport, generation);
|
|
@@ -493,12 +581,18 @@ const useMcpServerResource = (
|
|
|
493
581
|
} else if (!persisted?.token) {
|
|
494
582
|
return;
|
|
495
583
|
}
|
|
584
|
+
const pendingAuthValidation = pendingAuthValidationRef.current;
|
|
585
|
+
if (pendingAuthValidation) {
|
|
586
|
+
await pendingAuthValidation.promise;
|
|
587
|
+
if (signal.cancelled || !isCurrentConnection(generation)) return;
|
|
588
|
+
}
|
|
496
589
|
void doConnect();
|
|
497
590
|
},
|
|
498
591
|
);
|
|
499
592
|
|
|
500
593
|
// Auto-connect on mount when usable auth exists.
|
|
501
594
|
useEffect(() => {
|
|
595
|
+
const transportCloseQueueRef = props.transportCloseQueueRef;
|
|
502
596
|
const previousDisposal = pendingDisposalRef.current;
|
|
503
597
|
if (previousDisposal) previousDisposal.cancelled = true;
|
|
504
598
|
const pendingDisposal = { cancelled: false };
|
|
@@ -509,14 +603,15 @@ const useMcpServerResource = (
|
|
|
509
603
|
return () => {
|
|
510
604
|
mountedRef.current = false;
|
|
511
605
|
signal.cancelled = true;
|
|
512
|
-
|
|
513
|
-
|
|
606
|
+
const task = transportCloseQueueRef.current.then(async () => {
|
|
607
|
+
await new Promise<void>((resolve) => queueMicrotask(resolve));
|
|
514
608
|
if (pendingDisposal.cancelled) return;
|
|
515
609
|
connectionGenerationRef.current += 1;
|
|
516
|
-
|
|
610
|
+
await Promise.all(detachTransports().map(closeTransportSafely));
|
|
517
611
|
});
|
|
612
|
+
transportCloseQueueRef.current = task;
|
|
518
613
|
};
|
|
519
|
-
}, []);
|
|
614
|
+
}, [props.transportCloseQueueRef]);
|
|
520
615
|
|
|
521
616
|
const state = useMemo<MCPServerState>(
|
|
522
617
|
() => ({
|
|
@@ -552,7 +647,7 @@ const useMcpServerResource = (
|
|
|
552
647
|
remove: async () => {
|
|
553
648
|
await doDisconnect();
|
|
554
649
|
try {
|
|
555
|
-
await props.storage
|
|
650
|
+
await clearOAuthProviderAuthState(props.storage, props.id);
|
|
556
651
|
await props.onRemove();
|
|
557
652
|
} catch (err) {
|
|
558
653
|
setLastError({
|
|
@@ -640,4 +735,30 @@ const useMcpServerResource = (
|
|
|
640
735
|
};
|
|
641
736
|
};
|
|
642
737
|
|
|
643
|
-
|
|
738
|
+
const McpServerResourceInstance = resource(useMcpServerResourceInstance);
|
|
739
|
+
|
|
740
|
+
export const McpServerResource = resource(function useMcpServerResource(
|
|
741
|
+
props: McpServerResourceProps,
|
|
742
|
+
): ClientOutput<"mcpServer"> {
|
|
743
|
+
const transportCloseQueueRef = useRef(Promise.resolve());
|
|
744
|
+
const dependencies = getConnectionDependencies(props);
|
|
745
|
+
const [connection, setConnection] = useState({ dependencies, generation: 0 });
|
|
746
|
+
let currentConnection = connection;
|
|
747
|
+
if (!areConnectionDependenciesEqual(connection.dependencies, dependencies)) {
|
|
748
|
+
currentConnection = {
|
|
749
|
+
dependencies,
|
|
750
|
+
generation: connection.generation + 1,
|
|
751
|
+
};
|
|
752
|
+
setConnection(currentConnection);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// Storage keys remounts through its optional scopeId rather than object
|
|
756
|
+
// identity, because a defaulted storage element is rebuilt on ordinary
|
|
757
|
+
// renders.
|
|
758
|
+
return useResource(
|
|
759
|
+
withKey(
|
|
760
|
+
currentConnection.generation,
|
|
761
|
+
McpServerResourceInstance({ ...props, transportCloseQueueRef }),
|
|
762
|
+
),
|
|
763
|
+
);
|
|
764
|
+
});
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { createTapRoot, useResource } from "@assistant-ui/tap";
|
|
1
|
+
import { createTapRoot, resource, useResource } from "@assistant-ui/tap";
|
|
2
|
+
import { useState } from "react";
|
|
2
3
|
import { auth, type FetchLike } from "@modelcontextprotocol/client";
|
|
3
4
|
import { describe, expect, it } from "vitest";
|
|
4
5
|
import { createOAuthProvider } from "../../auth/createOAuthProvider";
|
|
5
6
|
|
|
7
|
+
import type { MCPStorage } from "./types";
|
|
6
8
|
import {
|
|
7
9
|
McpLocalStorage,
|
|
8
10
|
normalizeCustomServerRecords,
|
|
@@ -125,10 +127,12 @@ describe("normalizePersistedAuthState", () => {
|
|
|
125
127
|
normalizePersistedAuthState({
|
|
126
128
|
token: "bearer-token",
|
|
127
129
|
codeVerifier: "pkce-verifier",
|
|
130
|
+
state: "aui-mcp:ZG9jcw.nonce",
|
|
128
131
|
}),
|
|
129
132
|
).toEqual({
|
|
130
133
|
token: "bearer-token",
|
|
131
134
|
codeVerifier: "pkce-verifier",
|
|
135
|
+
state: "aui-mcp:ZG9jcw.nonce",
|
|
132
136
|
});
|
|
133
137
|
});
|
|
134
138
|
|
|
@@ -394,6 +398,7 @@ describe("McpLocalStorage auth state", () => {
|
|
|
394
398
|
JSON.parse(storage.getItem("test-mcp:auth:docs") ?? "null"),
|
|
395
399
|
).toMatchObject({
|
|
396
400
|
codeVerifier: expect.any(String),
|
|
401
|
+
state: authorizationUrls[0]!.searchParams.get("state"),
|
|
397
402
|
discoveryState: {
|
|
398
403
|
authorizationServerUrl: "https://auth.example.com",
|
|
399
404
|
},
|
|
@@ -418,3 +423,68 @@ describe("McpLocalStorage auth state", () => {
|
|
|
418
423
|
});
|
|
419
424
|
});
|
|
420
425
|
});
|
|
426
|
+
|
|
427
|
+
describe("McpLocalStorage instance identity", () => {
|
|
428
|
+
it("derives a scope from the prefix for the shared default backing", () => {
|
|
429
|
+
let storage!: MCPStorage;
|
|
430
|
+
|
|
431
|
+
createTapRoot(function McpStorageScopeRoot() {
|
|
432
|
+
storage = useResource(McpLocalStorage({ keyPrefix: "test-mcp" }));
|
|
433
|
+
return storage;
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
expect(storage.scopeId).toBe("local-storage:test-mcp");
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it("declares no scope for a custom backing store unless one is named", () => {
|
|
440
|
+
const backing = createStorage();
|
|
441
|
+
let unnamed!: MCPStorage;
|
|
442
|
+
let named!: MCPStorage;
|
|
443
|
+
|
|
444
|
+
createTapRoot(function McpStorageCustomScopeRoot() {
|
|
445
|
+
unnamed = useResource(
|
|
446
|
+
McpLocalStorage({ keyPrefix: "test-mcp", storage: backing }),
|
|
447
|
+
);
|
|
448
|
+
return unnamed;
|
|
449
|
+
});
|
|
450
|
+
createTapRoot(function McpStorageNamedScopeRoot() {
|
|
451
|
+
named = useResource(
|
|
452
|
+
McpLocalStorage({
|
|
453
|
+
keyPrefix: "test-mcp",
|
|
454
|
+
storage: backing,
|
|
455
|
+
scopeId: "session:alpha",
|
|
456
|
+
}),
|
|
457
|
+
);
|
|
458
|
+
return named;
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
expect(unnamed.scopeId).toBeUndefined();
|
|
462
|
+
expect(named.scopeId).toBe("session:alpha");
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it("returns the same instance across re-renders", () => {
|
|
466
|
+
const backing = createStorage();
|
|
467
|
+
const seen: MCPStorage[] = [];
|
|
468
|
+
let rerender!: () => void;
|
|
469
|
+
|
|
470
|
+
const useHost = () => {
|
|
471
|
+
const [, setTick] = useState(0);
|
|
472
|
+
rerender = () => setTick((n) => n + 1);
|
|
473
|
+
const storage = useResource(
|
|
474
|
+
McpLocalStorage({ keyPrefix: "test-mcp", storage: backing }),
|
|
475
|
+
);
|
|
476
|
+
seen.push(storage);
|
|
477
|
+
return storage;
|
|
478
|
+
};
|
|
479
|
+
const Host = resource(useHost);
|
|
480
|
+
|
|
481
|
+
createTapRoot(function McpStorageIdentityRoot() {
|
|
482
|
+
return useResource(Host());
|
|
483
|
+
});
|
|
484
|
+
rerender();
|
|
485
|
+
rerender();
|
|
486
|
+
|
|
487
|
+
expect(seen.length).toBeGreaterThan(1);
|
|
488
|
+
expect(new Set(seen).size).toBe(1);
|
|
489
|
+
});
|
|
490
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { resource } from "@assistant-ui/tap";
|
|
2
|
+
import { useMemo } from "react";
|
|
2
3
|
import {
|
|
3
4
|
OAuthMetadataSchema,
|
|
4
5
|
OAuthClientInformationFullSchema,
|
|
@@ -15,6 +16,13 @@ export type McpLocalStorageOptions = {
|
|
|
15
16
|
keyPrefix?: string;
|
|
16
17
|
/** Override the underlying Storage. Defaults to globalThis.localStorage. */
|
|
17
18
|
storage?: Storage;
|
|
19
|
+
/**
|
|
20
|
+
* Stable identity for the backing data, used to key server reconnects.
|
|
21
|
+
* Required to get reconnect-on-swap behavior when `storage` is overridden;
|
|
22
|
+
* without it a custom backing store declares no scope, since a prefix
|
|
23
|
+
* alone cannot distinguish two different stores.
|
|
24
|
+
*/
|
|
25
|
+
scopeId?: string;
|
|
18
26
|
};
|
|
19
27
|
|
|
20
28
|
function resolveStorage(opts: McpLocalStorageOptions): Storage | null {
|
|
@@ -193,6 +201,7 @@ export const normalizePersistedAuthState = (
|
|
|
193
201
|
if (isNonEmptyString(value.codeVerifier)) {
|
|
194
202
|
state.codeVerifier = value.codeVerifier;
|
|
195
203
|
}
|
|
204
|
+
if (isNonEmptyString(value.state)) state.state = value.state;
|
|
196
205
|
|
|
197
206
|
const tokens = normalizeOAuthTokens(value.tokens);
|
|
198
207
|
if (tokens) state.tokens = tokens;
|
|
@@ -208,54 +217,67 @@ export const normalizePersistedAuthState = (
|
|
|
208
217
|
|
|
209
218
|
const useMcpLocalStorage = (opts: McpLocalStorageOptions = {}): MCPStorage => {
|
|
210
219
|
const prefix = opts.keyPrefix ?? "aui-mcp";
|
|
211
|
-
const customServersKey = `${prefix}:custom-servers`;
|
|
212
|
-
const authKey = (id: string) => `${prefix}:auth:${id}`;
|
|
213
220
|
const storage = resolveStorage(opts);
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
221
|
+
// Deriving a scope from the prefix is only honest for the shared
|
|
222
|
+
// globalThis.localStorage; two custom backing stores under one prefix hold
|
|
223
|
+
// different data, so an overridden backing declares no scope unless the
|
|
224
|
+
// caller names one.
|
|
225
|
+
const scopeId =
|
|
226
|
+
opts.scopeId ??
|
|
227
|
+
(opts.storage === undefined ? `local-storage:${prefix}` : undefined);
|
|
228
|
+
|
|
229
|
+
// Callers key per-server coordination state on this instance, so it has to
|
|
230
|
+
// stay referentially stable for as long as the underlying store does.
|
|
231
|
+
return useMemo(() => {
|
|
232
|
+
const customServersKey = `${prefix}:custom-servers`;
|
|
233
|
+
const authKey = (id: string) => `${prefix}:auth:${id}`;
|
|
234
|
+
|
|
235
|
+
const read = <T>(key: string, fallback: T): T => {
|
|
236
|
+
if (!storage) return fallback;
|
|
237
|
+
try {
|
|
238
|
+
const raw = storage.getItem(key);
|
|
239
|
+
if (raw == null) return fallback;
|
|
240
|
+
return JSON.parse(raw) as T;
|
|
241
|
+
} catch {
|
|
242
|
+
return fallback;
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const write = (key: string, value: unknown): void => {
|
|
247
|
+
if (!storage) return;
|
|
248
|
+
try {
|
|
249
|
+
storage.setItem(key, JSON.stringify(value));
|
|
250
|
+
} catch {
|
|
251
|
+
// quota or serialization failure — silently drop
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const remove = (key: string): void => {
|
|
256
|
+
if (!storage) return;
|
|
257
|
+
try {
|
|
258
|
+
storage.removeItem(key);
|
|
259
|
+
} catch {
|
|
260
|
+
// ignore
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
...(scopeId !== undefined ? { scopeId } : {}),
|
|
266
|
+
loadCustomServers: async () =>
|
|
267
|
+
normalizeCustomServerRecords(read<unknown>(customServersKey, [])),
|
|
268
|
+
saveCustomServers: async (records) => {
|
|
269
|
+
write(customServersKey, records);
|
|
270
|
+
},
|
|
271
|
+
loadAuthState: async (id) =>
|
|
272
|
+
normalizePersistedAuthState(read<unknown>(authKey(id), null)),
|
|
273
|
+
saveAuthState: async (id, state) => {
|
|
274
|
+
write(authKey(id), state);
|
|
275
|
+
},
|
|
276
|
+
clearAuthState: async (id) => {
|
|
277
|
+
remove(authKey(id));
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
}, [prefix, storage, scopeId]);
|
|
259
281
|
};
|
|
260
282
|
|
|
261
283
|
export const McpLocalStorage = resource(useMcpLocalStorage);
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { createTapRoot, resource, useResource } from "@assistant-ui/tap";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import type { MCPStorage } from "./types";
|
|
5
|
+
import { McpMemoryStorage } from "./McpMemoryStorage";
|
|
6
|
+
|
|
7
|
+
const mountWithRerender = () => {
|
|
8
|
+
const seen: MCPStorage[] = [];
|
|
9
|
+
let setTick!: (update: (value: number) => number) => void;
|
|
10
|
+
|
|
11
|
+
const useHost = () => {
|
|
12
|
+
const [, setValue] = useState(0);
|
|
13
|
+
setTick = setValue;
|
|
14
|
+
const storage = useResource(McpMemoryStorage());
|
|
15
|
+
seen.push(storage);
|
|
16
|
+
return storage;
|
|
17
|
+
};
|
|
18
|
+
const Host = resource(useHost);
|
|
19
|
+
|
|
20
|
+
createTapRoot(function MemoryStorageRoot() {
|
|
21
|
+
return useResource(Host());
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
seen,
|
|
26
|
+
latest: () => seen[seen.length - 1]!,
|
|
27
|
+
rerender: () => setTick((value) => value + 1),
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
describe("McpMemoryStorage", () => {
|
|
32
|
+
it("returns the same instance across re-renders", () => {
|
|
33
|
+
const { seen, rerender } = mountWithRerender();
|
|
34
|
+
|
|
35
|
+
rerender();
|
|
36
|
+
rerender();
|
|
37
|
+
|
|
38
|
+
expect(seen.length).toBeGreaterThan(1);
|
|
39
|
+
expect(new Set(seen).size).toBe(1);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("keeps persisted auth state across re-renders", async () => {
|
|
43
|
+
const { seen, latest, rerender } = mountWithRerender();
|
|
44
|
+
await seen[0]!.saveAuthState("docs", { codeVerifier: "pkce-verifier" });
|
|
45
|
+
|
|
46
|
+
rerender();
|
|
47
|
+
|
|
48
|
+
await expect(latest().loadAuthState("docs")).resolves.toEqual({
|
|
49
|
+
codeVerifier: "pkce-verifier",
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("keeps custom servers across re-renders", async () => {
|
|
54
|
+
const { seen, latest, rerender } = mountWithRerender();
|
|
55
|
+
await seen[0]!.saveCustomServers([
|
|
56
|
+
{
|
|
57
|
+
id: "docs",
|
|
58
|
+
name: "Docs",
|
|
59
|
+
url: "https://docs.example.com/mcp",
|
|
60
|
+
auth: { type: "none" },
|
|
61
|
+
createdAt: 1,
|
|
62
|
+
},
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
rerender();
|
|
66
|
+
|
|
67
|
+
await expect(latest().loadCustomServers()).resolves.toHaveLength(1);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("McpMemoryStorage scope identity", () => {
|
|
72
|
+
it("scopes each instance uniquely and keeps it stable per instance", () => {
|
|
73
|
+
let first!: MCPStorage;
|
|
74
|
+
let second!: MCPStorage;
|
|
75
|
+
const seen: (string | undefined)[] = [];
|
|
76
|
+
let rerender!: () => void;
|
|
77
|
+
|
|
78
|
+
const Host = resource(function useHost() {
|
|
79
|
+
const [, setTick] = useState(0);
|
|
80
|
+
rerender = () => setTick((n) => n + 1);
|
|
81
|
+
first = useResource(McpMemoryStorage());
|
|
82
|
+
seen.push(first.scopeId);
|
|
83
|
+
return first;
|
|
84
|
+
});
|
|
85
|
+
createTapRoot(function MemoryScopeRootA() {
|
|
86
|
+
return useResource(Host());
|
|
87
|
+
});
|
|
88
|
+
createTapRoot(function MemoryScopeRootB() {
|
|
89
|
+
second = useResource(McpMemoryStorage());
|
|
90
|
+
return second;
|
|
91
|
+
});
|
|
92
|
+
rerender();
|
|
93
|
+
|
|
94
|
+
expect(first.scopeId).toMatch(/^memory:/);
|
|
95
|
+
expect(second.scopeId).toMatch(/^memory:/);
|
|
96
|
+
expect(first.scopeId).not.toBe(second.scopeId);
|
|
97
|
+
expect(new Set(seen).size).toBe(1);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -1,24 +1,30 @@
|
|
|
1
1
|
import { resource } from "@assistant-ui/tap";
|
|
2
|
+
import { useMemo } from "react";
|
|
3
|
+
import { generateId } from "@assistant-ui/core";
|
|
2
4
|
import type { MCPCustomServerRecord } from "../../mcp-scope";
|
|
3
5
|
import type { MCPPersistedAuthState } from "../../auth/types";
|
|
4
6
|
import type { MCPStorage } from "./types";
|
|
5
7
|
|
|
6
|
-
const useMcpMemoryStorage = (): MCPStorage =>
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
8
|
+
const useMcpMemoryStorage = (): MCPStorage =>
|
|
9
|
+
useMemo(() => {
|
|
10
|
+
let servers: MCPCustomServerRecord[] = [];
|
|
11
|
+
const auth = new Map<string, MCPPersistedAuthState>();
|
|
12
|
+
return {
|
|
13
|
+
// Each memory store is its own private data: a distinct instance is a
|
|
14
|
+
// distinct scope, so replacing one keys a reconnect.
|
|
15
|
+
scopeId: `memory:${generateId()}`,
|
|
16
|
+
loadCustomServers: async () => [...servers],
|
|
17
|
+
saveCustomServers: async (records) => {
|
|
18
|
+
servers = [...records];
|
|
19
|
+
},
|
|
20
|
+
loadAuthState: async (id) => auth.get(id) ?? null,
|
|
21
|
+
saveAuthState: async (id, state) => {
|
|
22
|
+
auth.set(id, state);
|
|
23
|
+
},
|
|
24
|
+
clearAuthState: async (id) => {
|
|
25
|
+
auth.delete(id);
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}, []);
|
|
23
29
|
|
|
24
30
|
export const McpMemoryStorage = resource(useMcpMemoryStorage);
|