@assistant-ui/react-mcp 0.1.15 → 0.1.17
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 +19 -2
- package/dist/auth/createOAuthProvider.d.ts.map +1 -1
- package/dist/auth/createOAuthProvider.js +197 -35
- package/dist/auth/createOAuthProvider.js.map +1 -1
- package/dist/auth/types.d.ts +6 -1
- 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/resources/McpManagerResource.d.ts.map +1 -1
- package/dist/resources/McpManagerResource.js +2 -1
- 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 +79 -19
- 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 +146 -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/serverUrl.d.ts +8 -0
- package/dist/utils/serverUrl.d.ts.map +1 -0
- package/dist/utils/serverUrl.js +15 -0
- package/dist/utils/serverUrl.js.map +1 -0
- package/package.json +7 -7
- package/src/auth/createOAuthProvider.test.ts +919 -5
- package/src/auth/createOAuthProvider.ts +328 -42
- package/src/auth/types.ts +6 -1
- package/src/hooks/useMcpOAuthCallback.test.ts +74 -1
- package/src/hooks/useMcpOAuthCallback.tsx +11 -8
- package/src/resources/McpManagerResource.ts +5 -1
- package/src/resources/McpServerResource.test.ts +612 -16
- package/src/resources/McpServerResource.ts +95 -23
- package/src/resources/storage/McpLocalStorage.test.ts +97 -1
- package/src/resources/storage/McpLocalStorage.ts +90 -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/serverUrl.test.ts +66 -0
- package/src/utils/serverUrl.ts +23 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useState, useRef, useEffect, useMemo, useEffectEvent } from "react";
|
|
2
2
|
import { resource, useResource, withKey } from "@assistant-ui/tap";
|
|
3
3
|
import type { ClientOutput } from "@assistant-ui/store";
|
|
4
|
+
import { shallowEqual } from "@assistant-ui/store/internal";
|
|
4
5
|
import {
|
|
5
6
|
Client,
|
|
6
7
|
StreamableHTTPClientTransport,
|
|
@@ -10,9 +11,17 @@ import {
|
|
|
10
11
|
type ElicitResult,
|
|
11
12
|
type StreamableHTTPClientTransportOptions,
|
|
12
13
|
} from "@modelcontextprotocol/client";
|
|
13
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
clearOAuthProviderAuthState,
|
|
16
|
+
createOAuthProvider,
|
|
17
|
+
hasUsableOAuthTokens,
|
|
18
|
+
} from "../auth/createOAuthProvider";
|
|
14
19
|
import { buildHeaders } from "../auth/buildHeaders";
|
|
15
20
|
import { assertValidServerId } from "../utils/serverId";
|
|
21
|
+
import {
|
|
22
|
+
hasPersistedCredentials,
|
|
23
|
+
isAuthStateForServerUrl,
|
|
24
|
+
} from "../utils/serverUrl";
|
|
16
25
|
import { validateElicitationContent } from "./validateElicitationContent";
|
|
17
26
|
import type { MCPStorage } from "./storage/types";
|
|
18
27
|
import type {
|
|
@@ -46,13 +55,13 @@ type McpServerResourceInstanceProps = McpServerResourceProps & {
|
|
|
46
55
|
transportCloseQueueRef: { current: Promise<void> };
|
|
47
56
|
};
|
|
48
57
|
|
|
49
|
-
const getConnectionDependencies = (
|
|
58
|
+
export const getConnectionDependencies = (
|
|
50
59
|
props: McpServerResourceProps,
|
|
51
60
|
): readonly unknown[] => {
|
|
52
61
|
const auth = props.auth;
|
|
53
62
|
const authDependencies =
|
|
54
63
|
auth.type === "bearer"
|
|
55
|
-
? [auth.type, auth.token]
|
|
64
|
+
? [auth.type, auth.token, props.storage.scopeId]
|
|
56
65
|
: auth.type === "oauth"
|
|
57
66
|
? [
|
|
58
67
|
auth.type,
|
|
@@ -63,6 +72,7 @@ const getConnectionDependencies = (
|
|
|
63
72
|
auth.registrationEndpoint,
|
|
64
73
|
auth.clientId,
|
|
65
74
|
auth.clientSecret,
|
|
75
|
+
props.storage.scopeId,
|
|
66
76
|
]
|
|
67
77
|
: [auth.type];
|
|
68
78
|
|
|
@@ -76,13 +86,6 @@ const getConnectionDependencies = (
|
|
|
76
86
|
];
|
|
77
87
|
};
|
|
78
88
|
|
|
79
|
-
const areConnectionDependenciesEqual = (
|
|
80
|
-
left: readonly unknown[],
|
|
81
|
-
right: readonly unknown[],
|
|
82
|
-
) =>
|
|
83
|
-
left.length === right.length &&
|
|
84
|
-
left.every((value, index) => Object.is(value, right[index]));
|
|
85
|
-
|
|
86
89
|
const useMcpServerResourceInstance = (
|
|
87
90
|
props: McpServerResourceInstanceProps,
|
|
88
91
|
): ClientOutput<"mcpServer"> => {
|
|
@@ -102,6 +105,11 @@ const useMcpServerResourceInstance = (
|
|
|
102
105
|
null,
|
|
103
106
|
);
|
|
104
107
|
const connectionGenerationRef = useRef(0);
|
|
108
|
+
const pendingAuthValidationRef = useRef<{
|
|
109
|
+
count: number;
|
|
110
|
+
promise: Promise<void>;
|
|
111
|
+
resolve: () => void;
|
|
112
|
+
} | null>(null);
|
|
105
113
|
const elicitationResolversRef = useRef(
|
|
106
114
|
new Map<
|
|
107
115
|
string,
|
|
@@ -233,11 +241,23 @@ const useMcpServerResourceInstance = (
|
|
|
233
241
|
},
|
|
234
242
|
);
|
|
235
243
|
|
|
244
|
+
const unboundAuthMessage = () =>
|
|
245
|
+
`MCP server "${props.id}" has saved authentication for a different URL. Authenticate again to connect to ${props.url}.`;
|
|
246
|
+
|
|
247
|
+
const loadAuthState = useEffectEvent(async () => {
|
|
248
|
+
const state = await props.storage.loadAuthState(props.id);
|
|
249
|
+
if (isAuthStateForServerUrl(state, props.url)) {
|
|
250
|
+
return { state, unbound: false };
|
|
251
|
+
}
|
|
252
|
+
return { state: null, unbound: hasPersistedCredentials(state) };
|
|
253
|
+
});
|
|
254
|
+
|
|
236
255
|
const buildTransport = useEffectEvent(
|
|
237
256
|
async (): Promise<StreamableHTTPClientTransport> => {
|
|
238
257
|
if (props.auth.type === "oauth") {
|
|
239
258
|
const authProvider = createOAuthProvider({
|
|
240
259
|
serverId: props.id,
|
|
260
|
+
serverUrl: props.url,
|
|
241
261
|
config: props.auth,
|
|
242
262
|
storage: props.storage,
|
|
243
263
|
redirectUri: props.redirectUri,
|
|
@@ -248,8 +268,9 @@ const useMcpServerResourceInstance = (
|
|
|
248
268
|
});
|
|
249
269
|
}
|
|
250
270
|
if (props.auth.type === "bearer") {
|
|
251
|
-
const
|
|
252
|
-
const headers = buildHeaders(props.auth,
|
|
271
|
+
const { state, unbound } = await loadAuthState();
|
|
272
|
+
const headers = buildHeaders(props.auth, state);
|
|
273
|
+
if (!headers && unbound) throw new Error(unboundAuthMessage());
|
|
253
274
|
const transportOpts: StreamableHTTPClientTransportOptions = {};
|
|
254
275
|
if (headers) transportOpts.requestInit = { headers };
|
|
255
276
|
return new StreamableHTTPClientTransport(
|
|
@@ -464,6 +485,46 @@ const useMcpServerResourceInstance = (
|
|
|
464
485
|
});
|
|
465
486
|
|
|
466
487
|
const doCompleteAuth = useEffectEvent(async (callbackUrl: string) => {
|
|
488
|
+
const validationGeneration = connectionGenerationRef.current;
|
|
489
|
+
const url = new URL(callbackUrl);
|
|
490
|
+
const state = url.searchParams.get("state");
|
|
491
|
+
if (!state) throw new Error('missing "state" parameter');
|
|
492
|
+
let pendingAuthValidation = pendingAuthValidationRef.current;
|
|
493
|
+
if (!pendingAuthValidation) {
|
|
494
|
+
let resolve!: () => void;
|
|
495
|
+
const promise = new Promise<void>((resolvePromise) => {
|
|
496
|
+
resolve = resolvePromise;
|
|
497
|
+
});
|
|
498
|
+
pendingAuthValidation = { count: 0, promise, resolve };
|
|
499
|
+
pendingAuthValidationRef.current = pendingAuthValidation;
|
|
500
|
+
}
|
|
501
|
+
pendingAuthValidation.count += 1;
|
|
502
|
+
try {
|
|
503
|
+
const { state: persisted, unbound } = await loadAuthState();
|
|
504
|
+
if (!isCurrentConnection(validationGeneration)) {
|
|
505
|
+
throw createInterruptedAuthError();
|
|
506
|
+
}
|
|
507
|
+
if (unbound) throw new Error(unboundAuthMessage());
|
|
508
|
+
if (!persisted?.state) {
|
|
509
|
+
throw new Error(
|
|
510
|
+
"no pending OAuth authorization request for this server",
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
if (persisted.state !== state) {
|
|
514
|
+
throw new Error("OAuth state does not match the authorization request");
|
|
515
|
+
}
|
|
516
|
+
if (!url.searchParams.get("code") && !url.searchParams.get("error")) {
|
|
517
|
+
throw new Error("missing authorization code in callback URL");
|
|
518
|
+
}
|
|
519
|
+
} finally {
|
|
520
|
+
pendingAuthValidation.count -= 1;
|
|
521
|
+
if (pendingAuthValidation.count === 0) {
|
|
522
|
+
pendingAuthValidationRef.current = null;
|
|
523
|
+
pendingAuthValidation.resolve();
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Claim the generation before a waiting auto-connect can resume.
|
|
467
528
|
const generation = ++connectionGenerationRef.current;
|
|
468
529
|
cancelPendingElicitations();
|
|
469
530
|
await closePendingTransport();
|
|
@@ -472,9 +533,6 @@ const useMcpServerResourceInstance = (
|
|
|
472
533
|
setConnectionState("authPending");
|
|
473
534
|
setLastError(null);
|
|
474
535
|
try {
|
|
475
|
-
const url = new URL(callbackUrl);
|
|
476
|
-
const code = url.searchParams.get("code");
|
|
477
|
-
if (!code) throw new Error("missing authorization code in callback URL");
|
|
478
536
|
let transport = transportRef.current;
|
|
479
537
|
if (!transport) {
|
|
480
538
|
transport = await buildTransport();
|
|
@@ -486,7 +544,7 @@ const useMcpServerResourceInstance = (
|
|
|
486
544
|
transportRef.current = null;
|
|
487
545
|
clientRef.current = null;
|
|
488
546
|
pendingTransportRef.current = transport;
|
|
489
|
-
await transport.finishAuth(
|
|
547
|
+
await transport.finishAuth(url.searchParams);
|
|
490
548
|
if (!isCurrentConnection(generation)) throw createInterruptedAuthError();
|
|
491
549
|
setAuthorizationUrl(null);
|
|
492
550
|
const connected = await finalizeConnect(transport, generation);
|
|
@@ -518,9 +576,9 @@ const useMcpServerResourceInstance = (
|
|
|
518
576
|
return;
|
|
519
577
|
}
|
|
520
578
|
const generation = connectionGenerationRef.current;
|
|
521
|
-
let
|
|
579
|
+
let loaded: Awaited<ReturnType<typeof loadAuthState>>;
|
|
522
580
|
try {
|
|
523
|
-
|
|
581
|
+
loaded = await loadAuthState();
|
|
524
582
|
} catch (error) {
|
|
525
583
|
if (signal.cancelled || !isCurrentConnection(generation)) return;
|
|
526
584
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -531,11 +589,21 @@ const useMcpServerResourceInstance = (
|
|
|
531
589
|
return;
|
|
532
590
|
}
|
|
533
591
|
if (signal.cancelled || !isCurrentConnection(generation)) return;
|
|
592
|
+
if (loaded.unbound) {
|
|
593
|
+
setLastError({ message: unboundAuthMessage() });
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
const persisted = loaded.state;
|
|
534
597
|
if (props.auth.type === "oauth") {
|
|
535
|
-
if (!persisted
|
|
598
|
+
if (!hasUsableOAuthTokens(persisted, props.auth)) return;
|
|
536
599
|
} else if (!persisted?.token) {
|
|
537
600
|
return;
|
|
538
601
|
}
|
|
602
|
+
const pendingAuthValidation = pendingAuthValidationRef.current;
|
|
603
|
+
if (pendingAuthValidation) {
|
|
604
|
+
await pendingAuthValidation.promise;
|
|
605
|
+
if (signal.cancelled || !isCurrentConnection(generation)) return;
|
|
606
|
+
}
|
|
539
607
|
void doConnect();
|
|
540
608
|
},
|
|
541
609
|
);
|
|
@@ -549,6 +617,9 @@ const useMcpServerResourceInstance = (
|
|
|
549
617
|
pendingDisposalRef.current = pendingDisposal;
|
|
550
618
|
mountedRef.current = true;
|
|
551
619
|
const signal = { cancelled: false };
|
|
620
|
+
// Auto-connect opens a transport, so it belongs to the same effect as the
|
|
621
|
+
// disposal that closes it.
|
|
622
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
552
623
|
void tryAutoConnect(signal);
|
|
553
624
|
return () => {
|
|
554
625
|
mountedRef.current = false;
|
|
@@ -597,7 +668,7 @@ const useMcpServerResourceInstance = (
|
|
|
597
668
|
remove: async () => {
|
|
598
669
|
await doDisconnect();
|
|
599
670
|
try {
|
|
600
|
-
await props.storage
|
|
671
|
+
await clearOAuthProviderAuthState(props.storage, props.id);
|
|
601
672
|
await props.onRemove();
|
|
602
673
|
} catch (err) {
|
|
603
674
|
setLastError({
|
|
@@ -694,7 +765,7 @@ export const McpServerResource = resource(function useMcpServerResource(
|
|
|
694
765
|
const dependencies = getConnectionDependencies(props);
|
|
695
766
|
const [connection, setConnection] = useState({ dependencies, generation: 0 });
|
|
696
767
|
let currentConnection = connection;
|
|
697
|
-
if (!
|
|
768
|
+
if (!shallowEqual(connection.dependencies, dependencies)) {
|
|
698
769
|
currentConnection = {
|
|
699
770
|
dependencies,
|
|
700
771
|
generation: connection.generation + 1,
|
|
@@ -702,8 +773,9 @@ export const McpServerResource = resource(function useMcpServerResource(
|
|
|
702
773
|
setConnection(currentConnection);
|
|
703
774
|
}
|
|
704
775
|
|
|
705
|
-
// Storage
|
|
706
|
-
//
|
|
776
|
+
// Storage keys remounts through its optional scopeId rather than object
|
|
777
|
+
// identity, because a defaulted storage element is rebuilt on ordinary
|
|
778
|
+
// renders.
|
|
707
779
|
return useResource(
|
|
708
780
|
withKey(
|
|
709
781
|
currentConnection.generation,
|
|
@@ -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,13 +127,36 @@ 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
|
|
|
139
|
+
it("keeps valid server URL bindings", () => {
|
|
140
|
+
expect(
|
|
141
|
+
normalizePersistedAuthState({
|
|
142
|
+
serverUrl: "http://mcp.example.com/docs",
|
|
143
|
+
token: "bearer-token",
|
|
144
|
+
}),
|
|
145
|
+
).toEqual({
|
|
146
|
+
serverUrl: "http://mcp.example.com/docs",
|
|
147
|
+
token: "bearer-token",
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("rejects auth state with an unsafe server URL binding", () => {
|
|
152
|
+
expect(
|
|
153
|
+
normalizePersistedAuthState({
|
|
154
|
+
serverUrl: "javascript:alert(1)",
|
|
155
|
+
token: "bearer-token",
|
|
156
|
+
}),
|
|
157
|
+
).toBeNull();
|
|
158
|
+
});
|
|
159
|
+
|
|
135
160
|
it("keeps valid OAuth tokens and client information", () => {
|
|
136
161
|
const tokens = {
|
|
137
162
|
access_token: "access-token",
|
|
@@ -149,11 +174,15 @@ describe("normalizePersistedAuthState", () => {
|
|
|
149
174
|
expect(
|
|
150
175
|
normalizePersistedAuthState({
|
|
151
176
|
tokens,
|
|
177
|
+
tokensClientId: "client-id",
|
|
152
178
|
clientInformation,
|
|
179
|
+
clientInformationSource: "registered",
|
|
153
180
|
}),
|
|
154
181
|
).toEqual({
|
|
155
182
|
tokens,
|
|
183
|
+
tokensClientId: "client-id",
|
|
156
184
|
clientInformation,
|
|
185
|
+
clientInformationSource: "registered",
|
|
157
186
|
});
|
|
158
187
|
});
|
|
159
188
|
|
|
@@ -373,6 +402,7 @@ describe("McpLocalStorage auth state", () => {
|
|
|
373
402
|
const createProvider = () =>
|
|
374
403
|
createOAuthProvider({
|
|
375
404
|
serverId: "docs",
|
|
405
|
+
serverUrl: "https://mcp.example.com/mcp",
|
|
376
406
|
config: { type: "oauth", clientId: "client-id" },
|
|
377
407
|
storage: loadStorage(storage),
|
|
378
408
|
redirectUri: "http://localhost/callback",
|
|
@@ -394,6 +424,7 @@ describe("McpLocalStorage auth state", () => {
|
|
|
394
424
|
JSON.parse(storage.getItem("test-mcp:auth:docs") ?? "null"),
|
|
395
425
|
).toMatchObject({
|
|
396
426
|
codeVerifier: expect.any(String),
|
|
427
|
+
state: authorizationUrls[0]!.searchParams.get("state"),
|
|
397
428
|
discoveryState: {
|
|
398
429
|
authorizationServerUrl: "https://auth.example.com",
|
|
399
430
|
},
|
|
@@ -418,3 +449,68 @@ describe("McpLocalStorage auth state", () => {
|
|
|
418
449
|
});
|
|
419
450
|
});
|
|
420
451
|
});
|
|
452
|
+
|
|
453
|
+
describe("McpLocalStorage instance identity", () => {
|
|
454
|
+
it("derives a scope from the prefix for the shared default backing", () => {
|
|
455
|
+
let storage!: MCPStorage;
|
|
456
|
+
|
|
457
|
+
createTapRoot(function McpStorageScopeRoot() {
|
|
458
|
+
storage = useResource(McpLocalStorage({ keyPrefix: "test-mcp" }));
|
|
459
|
+
return storage;
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
expect(storage.scopeId).toBe("local-storage:test-mcp");
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it("declares no scope for a custom backing store unless one is named", () => {
|
|
466
|
+
const backing = createStorage();
|
|
467
|
+
let unnamed!: MCPStorage;
|
|
468
|
+
let named!: MCPStorage;
|
|
469
|
+
|
|
470
|
+
createTapRoot(function McpStorageCustomScopeRoot() {
|
|
471
|
+
unnamed = useResource(
|
|
472
|
+
McpLocalStorage({ keyPrefix: "test-mcp", storage: backing }),
|
|
473
|
+
);
|
|
474
|
+
return unnamed;
|
|
475
|
+
});
|
|
476
|
+
createTapRoot(function McpStorageNamedScopeRoot() {
|
|
477
|
+
named = useResource(
|
|
478
|
+
McpLocalStorage({
|
|
479
|
+
keyPrefix: "test-mcp",
|
|
480
|
+
storage: backing,
|
|
481
|
+
scopeId: "session:alpha",
|
|
482
|
+
}),
|
|
483
|
+
);
|
|
484
|
+
return named;
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
expect(unnamed.scopeId).toBeUndefined();
|
|
488
|
+
expect(named.scopeId).toBe("session:alpha");
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it("returns the same instance across re-renders", () => {
|
|
492
|
+
const backing = createStorage();
|
|
493
|
+
const seen: MCPStorage[] = [];
|
|
494
|
+
let rerender!: () => void;
|
|
495
|
+
|
|
496
|
+
const useHost = () => {
|
|
497
|
+
const [, setTick] = useState(0);
|
|
498
|
+
rerender = () => setTick((n) => n + 1);
|
|
499
|
+
const storage = useResource(
|
|
500
|
+
McpLocalStorage({ keyPrefix: "test-mcp", storage: backing }),
|
|
501
|
+
);
|
|
502
|
+
seen.push(storage);
|
|
503
|
+
return storage;
|
|
504
|
+
};
|
|
505
|
+
const Host = resource(useHost);
|
|
506
|
+
|
|
507
|
+
createTapRoot(function McpStorageIdentityRoot() {
|
|
508
|
+
return useResource(Host());
|
|
509
|
+
});
|
|
510
|
+
rerender();
|
|
511
|
+
rerender();
|
|
512
|
+
|
|
513
|
+
expect(seen.length).toBeGreaterThan(1);
|
|
514
|
+
expect(new Set(seen).size).toBe(1);
|
|
515
|
+
});
|
|
516
|
+
});
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { resource } from "@assistant-ui/tap";
|
|
2
|
+
import { useMemo } from "react";
|
|
2
3
|
import {
|
|
3
4
|
OAuthMetadataSchema,
|
|
4
5
|
OAuthClientInformationFullSchema,
|
|
5
6
|
OAuthProtectedResourceMetadataSchema,
|
|
6
7
|
OAuthTokensSchema,
|
|
7
8
|
} from "@modelcontextprotocol/core";
|
|
9
|
+
import { normalizeMcpServerUrl } from "../../utils/serverUrl";
|
|
8
10
|
import type { MCPAuthConfig, MCPCustomServerRecord } from "../../mcp-scope";
|
|
9
11
|
import type { MCPPersistedAuthState } from "../../auth/types";
|
|
10
12
|
import { assertValidServerId } from "../../utils/serverId";
|
|
@@ -15,6 +17,13 @@ export type McpLocalStorageOptions = {
|
|
|
15
17
|
keyPrefix?: string;
|
|
16
18
|
/** Override the underlying Storage. Defaults to globalThis.localStorage. */
|
|
17
19
|
storage?: Storage;
|
|
20
|
+
/**
|
|
21
|
+
* Stable identity for the backing data, used to key server reconnects.
|
|
22
|
+
* Required to get reconnect-on-swap behavior when `storage` is overridden;
|
|
23
|
+
* without it a custom backing store declares no scope, since a prefix
|
|
24
|
+
* alone cannot distinguish two different stores.
|
|
25
|
+
*/
|
|
26
|
+
scopeId?: string;
|
|
18
27
|
};
|
|
19
28
|
|
|
20
29
|
function resolveStorage(opts: McpLocalStorageOptions): Storage | null {
|
|
@@ -152,6 +161,16 @@ const isSecureNetworkUrl = (value: unknown): value is string => {
|
|
|
152
161
|
}
|
|
153
162
|
};
|
|
154
163
|
|
|
164
|
+
const isMcpServerUrl = (value: unknown): value is string => {
|
|
165
|
+
if (typeof value !== "string") return false;
|
|
166
|
+
try {
|
|
167
|
+
const url = new URL(value);
|
|
168
|
+
return url.protocol === "https:" || url.protocol === "http:";
|
|
169
|
+
} catch {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
|
|
155
174
|
const normalizeDiscoveryState = (
|
|
156
175
|
value: unknown,
|
|
157
176
|
): MCPPersistedAuthState["discoveryState"] | undefined => {
|
|
@@ -187,18 +206,29 @@ export const normalizePersistedAuthState = (
|
|
|
187
206
|
value: unknown,
|
|
188
207
|
): MCPPersistedAuthState | null => {
|
|
189
208
|
if (!isRecord(value)) return null;
|
|
209
|
+
if ("serverUrl" in value && !isMcpServerUrl(value.serverUrl)) return null;
|
|
190
210
|
|
|
191
211
|
const state: MCPPersistedAuthState = {};
|
|
212
|
+
if (isMcpServerUrl(value.serverUrl)) {
|
|
213
|
+
state.serverUrl = normalizeMcpServerUrl(value.serverUrl);
|
|
214
|
+
}
|
|
192
215
|
if (isNonEmptyString(value.token)) state.token = value.token;
|
|
216
|
+
if (isNonEmptyString(value.tokensClientId)) {
|
|
217
|
+
state.tokensClientId = value.tokensClientId;
|
|
218
|
+
}
|
|
193
219
|
if (isNonEmptyString(value.codeVerifier)) {
|
|
194
220
|
state.codeVerifier = value.codeVerifier;
|
|
195
221
|
}
|
|
222
|
+
if (isNonEmptyString(value.state)) state.state = value.state;
|
|
196
223
|
|
|
197
224
|
const tokens = normalizeOAuthTokens(value.tokens);
|
|
198
225
|
if (tokens) state.tokens = tokens;
|
|
199
226
|
|
|
200
227
|
const clientInformation = normalizeClientInformation(value.clientInformation);
|
|
201
228
|
if (clientInformation) state.clientInformation = clientInformation;
|
|
229
|
+
if (value.clientInformationSource === "registered") {
|
|
230
|
+
state.clientInformationSource = value.clientInformationSource;
|
|
231
|
+
}
|
|
202
232
|
|
|
203
233
|
const discoveryState = normalizeDiscoveryState(value.discoveryState);
|
|
204
234
|
if (discoveryState) state.discoveryState = discoveryState;
|
|
@@ -208,54 +238,67 @@ export const normalizePersistedAuthState = (
|
|
|
208
238
|
|
|
209
239
|
const useMcpLocalStorage = (opts: McpLocalStorageOptions = {}): MCPStorage => {
|
|
210
240
|
const prefix = opts.keyPrefix ?? "aui-mcp";
|
|
211
|
-
const customServersKey = `${prefix}:custom-servers`;
|
|
212
|
-
const authKey = (id: string) => `${prefix}:auth:${id}`;
|
|
213
241
|
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
|
-
|
|
242
|
+
// Deriving a scope from the prefix is only honest for the shared
|
|
243
|
+
// globalThis.localStorage; two custom backing stores under one prefix hold
|
|
244
|
+
// different data, so an overridden backing declares no scope unless the
|
|
245
|
+
// caller names one.
|
|
246
|
+
const scopeId =
|
|
247
|
+
opts.scopeId ??
|
|
248
|
+
(opts.storage === undefined ? `local-storage:${prefix}` : undefined);
|
|
249
|
+
|
|
250
|
+
// Callers key per-server coordination state on this instance, so it has to
|
|
251
|
+
// stay referentially stable for as long as the underlying store does.
|
|
252
|
+
return useMemo(() => {
|
|
253
|
+
const customServersKey = `${prefix}:custom-servers`;
|
|
254
|
+
const authKey = (id: string) => `${prefix}:auth:${id}`;
|
|
255
|
+
|
|
256
|
+
const read = <T>(key: string, fallback: T): T => {
|
|
257
|
+
if (!storage) return fallback;
|
|
258
|
+
try {
|
|
259
|
+
const raw = storage.getItem(key);
|
|
260
|
+
if (raw == null) return fallback;
|
|
261
|
+
return JSON.parse(raw) as T;
|
|
262
|
+
} catch {
|
|
263
|
+
return fallback;
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const write = (key: string, value: unknown): void => {
|
|
268
|
+
if (!storage) return;
|
|
269
|
+
try {
|
|
270
|
+
storage.setItem(key, JSON.stringify(value));
|
|
271
|
+
} catch {
|
|
272
|
+
// quota or serialization failure — silently drop
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const remove = (key: string): void => {
|
|
277
|
+
if (!storage) return;
|
|
278
|
+
try {
|
|
279
|
+
storage.removeItem(key);
|
|
280
|
+
} catch {
|
|
281
|
+
// ignore
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
...(scopeId !== undefined ? { scopeId } : {}),
|
|
287
|
+
loadCustomServers: async () =>
|
|
288
|
+
normalizeCustomServerRecords(read<unknown>(customServersKey, [])),
|
|
289
|
+
saveCustomServers: async (records) => {
|
|
290
|
+
write(customServersKey, records);
|
|
291
|
+
},
|
|
292
|
+
loadAuthState: async (id) =>
|
|
293
|
+
normalizePersistedAuthState(read<unknown>(authKey(id), null)),
|
|
294
|
+
saveAuthState: async (id, state) => {
|
|
295
|
+
write(authKey(id), state);
|
|
296
|
+
},
|
|
297
|
+
clearAuthState: async (id) => {
|
|
298
|
+
remove(authKey(id));
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
}, [prefix, storage, scopeId]);
|
|
259
302
|
};
|
|
260
303
|
|
|
261
304
|
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
|
+
});
|