@assistant-ui/react-mcp 0.1.15 → 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/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 +50 -11
- 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/package.json +7 -7
- 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.ts +2 -1
- package/src/resources/McpServerResource.test.ts +377 -16
- package/src/resources/McpServerResource.ts +61 -10
- 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
|
@@ -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);
|
|
@@ -3,6 +3,18 @@ import type { MCPCustomServerRecord } from "../../mcp-scope";
|
|
|
3
3
|
import type { MCPPersistedAuthState } from "../../auth/types";
|
|
4
4
|
|
|
5
5
|
export type MCPStorage = {
|
|
6
|
+
/**
|
|
7
|
+
* Stable identity of the backing store. Two storages with the same scopeId
|
|
8
|
+
* must read and write the same persisted data. When present, server
|
|
9
|
+
* connections and the OAuth write fence key on it, so swapping to a
|
|
10
|
+
* differently-scoped storage reconnects instead of leaving a live OAuth flow
|
|
11
|
+
* on the replaced store, and clearing through a same-scoped replacement still
|
|
12
|
+
* waits for writes queued against the storage it replaced. When absent, the
|
|
13
|
+
* fence falls back to object identity while connections never re-key at all,
|
|
14
|
+
* so a storage rebuilt on every render has to declare a scopeId; without one
|
|
15
|
+
* a clear runs unfenced against the writes queued by the object it replaced.
|
|
16
|
+
*/
|
|
17
|
+
scopeId?: string;
|
|
6
18
|
loadCustomServers: () => Promise<MCPCustomServerRecord[]>;
|
|
7
19
|
saveCustomServers: (records: MCPCustomServerRecord[]) => Promise<void>;
|
|
8
20
|
loadAuthState: (serverId: string) => Promise<MCPPersistedAuthState | null>;
|