@assistant-ui/react-mcp 0.0.18 → 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/primitives/addForm/McpAddFormAuthSelect.js.map +1 -1
- package/dist/primitives/addForm/McpAddFormCancel.d.ts.map +1 -1
- package/dist/primitives/addForm/McpAddFormError.d.ts.map +1 -1
- package/dist/primitives/addForm/McpAddFormError.js.map +1 -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.map +1 -1
- package/dist/primitives/manager/McpManagerAddCustomTrigger.d.ts.map +1 -1
- package/dist/primitives/manager/McpManagerConnectors.js.map +1 -1
- package/dist/primitives/manager/McpManagerCustomServers.js.map +1 -1
- package/dist/primitives/manager/McpManagerRoot.d.ts.map +1 -1
- package/dist/primitives/server/McpServerConnectButton.d.ts.map +1 -1
- package/dist/primitives/server/McpServerDisconnectButton.d.ts.map +1 -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.map +1 -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.map +1 -1
- package/dist/primitives/server/McpServerRemoveButton.d.ts.map +1 -1
- package/dist/primitives/server/McpServerRoot.d.ts.map +1 -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.map +1 -1
- package/dist/primitives/server/McpServerToolName.js.map +1 -1
- package/dist/primitives/server/McpServerTools.js.map +1 -1
- package/dist/resources/McpServerResource.d.ts.map +1 -1
- package/dist/resources/McpServerResource.js +89 -31
- package/dist/resources/McpServerResource.js.map +1 -1
- package/dist/resources/storage/McpLocalStorage.d.ts.map +1 -1
- package/dist/resources/storage/McpLocalStorage.js +13 -2
- package/dist/resources/storage/McpLocalStorage.js.map +1 -1
- package/package.json +8 -8
- package/src/resources/McpServerResource.test.ts +179 -6
- package/src/resources/McpServerResource.ts +114 -29
- package/src/resources/storage/McpLocalStorage.test.ts +56 -0
- package/src/resources/storage/McpLocalStorage.ts +23 -2
|
@@ -16,6 +16,7 @@ const isOptionalString = (value) => value === void 0 || typeof value === "string
|
|
|
16
16
|
const isNonEmptyString = (value) => typeof value === "string" && value.trim().length > 0;
|
|
17
17
|
const isOptionalNonEmptyString = (value) => value === void 0 || isNonEmptyString(value);
|
|
18
18
|
const isOptionalStringArray = (value) => value === void 0 || Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
19
|
+
const isOptionalConnectionTimeout = (value) => value === void 0 || typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
19
20
|
const isValidServerId = (id) => {
|
|
20
21
|
try {
|
|
21
22
|
assertValidServerId(id);
|
|
@@ -36,11 +37,21 @@ const isMCPAuthConfig = (auth) => {
|
|
|
36
37
|
const isCustomServerRecord = (value) => {
|
|
37
38
|
if (!isRecord(value)) return false;
|
|
38
39
|
if (typeof value.id !== "string" || !isValidServerId(value.id)) return false;
|
|
39
|
-
return isNonEmptyString(value.name) && isNonEmptyString(value.url) && Number.isFinite(value.createdAt) && isMCPAuthConfig(value.auth);
|
|
40
|
+
return isNonEmptyString(value.name) && isNonEmptyString(value.url) && Number.isFinite(value.createdAt) && isMCPAuthConfig(value.auth) && isOptionalConnectionTimeout(value.connectionTimeout);
|
|
41
|
+
};
|
|
42
|
+
const normalizeCustomServerRecord = (value) => {
|
|
43
|
+
if (isCustomServerRecord(value)) return value;
|
|
44
|
+
if (!isRecord(value)) return null;
|
|
45
|
+
const record = { ...value };
|
|
46
|
+
delete record.connectionTimeout;
|
|
47
|
+
return isCustomServerRecord(record) ? record : null;
|
|
40
48
|
};
|
|
41
49
|
const normalizeCustomServerRecords = (value) => {
|
|
42
50
|
if (!Array.isArray(value)) return [];
|
|
43
|
-
return value.
|
|
51
|
+
return value.flatMap((item) => {
|
|
52
|
+
const record = normalizeCustomServerRecord(item);
|
|
53
|
+
return record === null ? [] : [record];
|
|
54
|
+
});
|
|
44
55
|
};
|
|
45
56
|
const normalizeOAuthTokens = (value) => {
|
|
46
57
|
const result = OAuthTokensSchema.safeParse(value);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"McpLocalStorage.js","names":["resource","OAuthClientInformationFullSchema","OAuthTokensSchema","MCPAuthConfig","MCPCustomServerRecord","MCPPersistedAuthState","assertValidServerId","MCPStorage","McpLocalStorageOptions","keyPrefix","storage","Storage","resolveStorage","opts","globalThis","localStorage","isRecord","value","Record","Array","isArray","isOptionalString","undefined","isNonEmptyString","trim","length","isOptionalNonEmptyString","isOptionalStringArray","every","item","isValidServerId","id","isMCPAuthConfig","auth","type","token","scopes","authorizationEndpoint","tokenEndpoint","registrationEndpoint","clientId","clientSecret","isCustomServerRecord","name","url","Number","isFinite","createdAt","normalizeCustomServerRecords","filter","normalizeOAuthTokens","result","safeParse","success","data","normalizeClientInformation","normalizePersistedAuthState","state","codeVerifier","tokens","clientInformation","Object","keys","useMcpLocalStorage","prefix","customServersKey","authKey","read","key","fallback","T","raw","getItem","JSON","parse","write","setItem","stringify","remove","removeItem","loadCustomServers","saveCustomServers","records","loadAuthState","saveAuthState","clearAuthState","McpLocalStorage"],"sources":["../../../src/resources/storage/McpLocalStorage.ts"],"sourcesContent":["import { resource } from \"@assistant-ui/tap\";\nimport {\n OAuthClientInformationFullSchema,\n OAuthTokensSchema,\n} from \"@modelcontextprotocol/sdk/shared/auth.js\";\nimport type { MCPAuthConfig, MCPCustomServerRecord } from \"../../mcp-scope\";\nimport type { MCPPersistedAuthState } from \"../../auth/types\";\nimport { assertValidServerId } from \"../../utils/serverId\";\nimport type { MCPStorage } from \"./types\";\n\nexport type McpLocalStorageOptions = {\n /** Namespace prefix for keys. Default \"aui-mcp\". */\n keyPrefix?: string;\n /** Override the underlying Storage. Defaults to globalThis.localStorage. */\n storage?: Storage;\n};\n\nfunction resolveStorage(opts: McpLocalStorageOptions): Storage | null {\n if (opts.storage) return opts.storage;\n if (typeof globalThis !== \"undefined\" && \"localStorage\" in globalThis) {\n try {\n return (globalThis as { localStorage: Storage }).localStorage;\n } catch {\n return null;\n }\n }\n return null;\n}\n\nconst isRecord = (value: unknown): value is Record<string, unknown> =>\n typeof value === \"object\" && value !== null && !Array.isArray(value);\n\nconst isOptionalString = (value: unknown): value is string | undefined =>\n value === undefined || typeof value === \"string\";\n\nconst isNonEmptyString = (value: unknown): value is string =>\n typeof value === \"string\" && value.trim().length > 0;\n\nconst isOptionalNonEmptyString = (\n value: unknown,\n): value is string | undefined =>\n value === undefined || isNonEmptyString(value);\n\nconst isOptionalStringArray = (value: unknown): value is string[] | undefined =>\n value === undefined ||\n (Array.isArray(value) && value.every((item) => typeof item === \"string\"));\n\nconst isValidServerId = (id: string): boolean => {\n try {\n assertValidServerId(id);\n return true;\n } catch {\n return false;\n }\n};\n\nconst isMCPAuthConfig = (auth: unknown): auth is MCPAuthConfig => {\n if (!isRecord(auth)) return false;\n\n switch (auth.type) {\n case \"none\":\n return true;\n case \"bearer\":\n return isOptionalNonEmptyString(auth.token);\n case \"oauth\":\n return (\n isOptionalStringArray(auth.scopes) &&\n isOptionalString(auth.authorizationEndpoint) &&\n isOptionalString(auth.tokenEndpoint) &&\n isOptionalString(auth.registrationEndpoint) &&\n isOptionalString(auth.clientId) &&\n isOptionalString(auth.clientSecret)\n );\n default:\n return false;\n }\n};\n\nconst isCustomServerRecord = (\n value: unknown,\n): value is MCPCustomServerRecord => {\n if (!isRecord(value)) return false;\n if (typeof value.id !== \"string\" || !isValidServerId(value.id)) {\n return false;\n }\n return (\n isNonEmptyString(value.name) &&\n isNonEmptyString(value.url) &&\n Number.isFinite(value.createdAt) &&\n isMCPAuthConfig(value.auth)\n );\n};\n\nexport const normalizeCustomServerRecords = (\n value: unknown,\n): MCPCustomServerRecord[] => {\n if (!Array.isArray(value)) return [];\n return value.filter(isCustomServerRecord);\n};\n\nconst normalizeOAuthTokens = (\n value: unknown,\n): MCPPersistedAuthState[\"tokens\"] | undefined => {\n const result = OAuthTokensSchema.safeParse(value);\n return result.success ? result.data : undefined;\n};\n\nconst normalizeClientInformation = (\n value: unknown,\n): MCPPersistedAuthState[\"clientInformation\"] | undefined => {\n const result = OAuthClientInformationFullSchema.safeParse(value);\n return result.success ? result.data : undefined;\n};\n\nexport const normalizePersistedAuthState = (\n value: unknown,\n): MCPPersistedAuthState | null => {\n if (!isRecord(value)) return null;\n\n const state: MCPPersistedAuthState = {};\n if (isNonEmptyString(value.token)) state.token = value.token;\n if (isNonEmptyString(value.codeVerifier)) {\n state.codeVerifier = value.codeVerifier;\n }\n\n const tokens = normalizeOAuthTokens(value.tokens);\n if (tokens) state.tokens = tokens;\n\n const clientInformation = normalizeClientInformation(value.clientInformation);\n if (clientInformation) state.clientInformation = clientInformation;\n\n return Object.keys(state).length > 0 ? state : null;\n};\n\nconst useMcpLocalStorage = (opts: McpLocalStorageOptions = {}): MCPStorage => {\n const prefix = opts.keyPrefix ?? \"aui-mcp\";\n const customServersKey = `${prefix}:custom-servers`;\n const authKey = (id: string) => `${prefix}:auth:${id}`;\n const storage = resolveStorage(opts);\n\n const read = <T>(key: string, fallback: T): T => {\n if (!storage) return fallback;\n try {\n const raw = storage.getItem(key);\n if (raw == null) return fallback;\n return JSON.parse(raw) as T;\n } catch {\n return fallback;\n }\n };\n\n const write = (key: string, value: unknown): void => {\n if (!storage) return;\n try {\n storage.setItem(key, JSON.stringify(value));\n } catch {\n // quota or serialization failure — silently drop\n }\n };\n\n const remove = (key: string): void => {\n if (!storage) return;\n try {\n storage.removeItem(key);\n } catch {\n // ignore\n }\n };\n\n return {\n loadCustomServers: async () =>\n normalizeCustomServerRecords(read<unknown>(customServersKey, [])),\n saveCustomServers: async (records) => {\n write(customServersKey, records);\n },\n loadAuthState: async (id) =>\n normalizePersistedAuthState(read<unknown>(authKey(id), null)),\n saveAuthState: async (id, state) => {\n write(authKey(id), state);\n },\n clearAuthState: async (id) => {\n remove(authKey(id));\n },\n };\n};\n\nexport const McpLocalStorage = resource(useMcpLocalStorage);\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"McpLocalStorage.js","names":["resource","OAuthClientInformationFullSchema","OAuthTokensSchema","MCPAuthConfig","MCPCustomServerRecord","MCPPersistedAuthState","assertValidServerId","MCPStorage","McpLocalStorageOptions","keyPrefix","storage","Storage","resolveStorage","opts","globalThis","localStorage","isRecord","value","Record","Array","isArray","isOptionalString","undefined","isNonEmptyString","trim","length","isOptionalNonEmptyString","isOptionalStringArray","every","item","isOptionalConnectionTimeout","Number","isFinite","isValidServerId","id","isMCPAuthConfig","auth","type","token","scopes","authorizationEndpoint","tokenEndpoint","registrationEndpoint","clientId","clientSecret","isCustomServerRecord","name","url","createdAt","connectionTimeout","normalizeCustomServerRecord","record","normalizeCustomServerRecords","flatMap","normalizeOAuthTokens","result","safeParse","success","data","normalizeClientInformation","normalizePersistedAuthState","state","codeVerifier","tokens","clientInformation","Object","keys","useMcpLocalStorage","prefix","customServersKey","authKey","read","key","fallback","T","raw","getItem","JSON","parse","write","setItem","stringify","remove","removeItem","loadCustomServers","saveCustomServers","records","loadAuthState","saveAuthState","clearAuthState","McpLocalStorage"],"sources":["../../../src/resources/storage/McpLocalStorage.ts"],"sourcesContent":["import { resource } from \"@assistant-ui/tap\";\nimport {\n OAuthClientInformationFullSchema,\n OAuthTokensSchema,\n} from \"@modelcontextprotocol/sdk/shared/auth.js\";\nimport type { MCPAuthConfig, MCPCustomServerRecord } from \"../../mcp-scope\";\nimport type { MCPPersistedAuthState } from \"../../auth/types\";\nimport { assertValidServerId } from \"../../utils/serverId\";\nimport type { MCPStorage } from \"./types\";\n\nexport type McpLocalStorageOptions = {\n /** Namespace prefix for keys. Default \"aui-mcp\". */\n keyPrefix?: string;\n /** Override the underlying Storage. Defaults to globalThis.localStorage. */\n storage?: Storage;\n};\n\nfunction resolveStorage(opts: McpLocalStorageOptions): Storage | null {\n if (opts.storage) return opts.storage;\n if (typeof globalThis !== \"undefined\" && \"localStorage\" in globalThis) {\n try {\n return (globalThis as { localStorage: Storage }).localStorage;\n } catch {\n return null;\n }\n }\n return null;\n}\n\nconst isRecord = (value: unknown): value is Record<string, unknown> =>\n typeof value === \"object\" && value !== null && !Array.isArray(value);\n\nconst isOptionalString = (value: unknown): value is string | undefined =>\n value === undefined || typeof value === \"string\";\n\nconst isNonEmptyString = (value: unknown): value is string =>\n typeof value === \"string\" && value.trim().length > 0;\n\nconst isOptionalNonEmptyString = (\n value: unknown,\n): value is string | undefined =>\n value === undefined || isNonEmptyString(value);\n\nconst isOptionalStringArray = (value: unknown): value is string[] | undefined =>\n value === undefined ||\n (Array.isArray(value) && value.every((item) => typeof item === \"string\"));\n\nconst isOptionalConnectionTimeout = (\n value: unknown,\n): value is number | undefined =>\n value === undefined ||\n (typeof value === \"number\" && Number.isFinite(value) && value >= 0);\n\nconst isValidServerId = (id: string): boolean => {\n try {\n assertValidServerId(id);\n return true;\n } catch {\n return false;\n }\n};\n\nconst isMCPAuthConfig = (auth: unknown): auth is MCPAuthConfig => {\n if (!isRecord(auth)) return false;\n\n switch (auth.type) {\n case \"none\":\n return true;\n case \"bearer\":\n return isOptionalNonEmptyString(auth.token);\n case \"oauth\":\n return (\n isOptionalStringArray(auth.scopes) &&\n isOptionalString(auth.authorizationEndpoint) &&\n isOptionalString(auth.tokenEndpoint) &&\n isOptionalString(auth.registrationEndpoint) &&\n isOptionalString(auth.clientId) &&\n isOptionalString(auth.clientSecret)\n );\n default:\n return false;\n }\n};\n\nconst isCustomServerRecord = (\n value: unknown,\n): value is MCPCustomServerRecord => {\n if (!isRecord(value)) return false;\n if (typeof value.id !== \"string\" || !isValidServerId(value.id)) {\n return false;\n }\n return (\n isNonEmptyString(value.name) &&\n isNonEmptyString(value.url) &&\n Number.isFinite(value.createdAt) &&\n isMCPAuthConfig(value.auth) &&\n isOptionalConnectionTimeout(value.connectionTimeout)\n );\n};\n\nconst normalizeCustomServerRecord = (\n value: unknown,\n): MCPCustomServerRecord | null => {\n if (isCustomServerRecord(value)) return value;\n if (!isRecord(value)) return null;\n\n const record = { ...value };\n delete record.connectionTimeout;\n return isCustomServerRecord(record) ? record : null;\n};\n\nexport const normalizeCustomServerRecords = (\n value: unknown,\n): MCPCustomServerRecord[] => {\n if (!Array.isArray(value)) return [];\n return value.flatMap((item) => {\n const record = normalizeCustomServerRecord(item);\n return record === null ? [] : [record];\n });\n};\n\nconst normalizeOAuthTokens = (\n value: unknown,\n): MCPPersistedAuthState[\"tokens\"] | undefined => {\n const result = OAuthTokensSchema.safeParse(value);\n return result.success ? result.data : undefined;\n};\n\nconst normalizeClientInformation = (\n value: unknown,\n): MCPPersistedAuthState[\"clientInformation\"] | undefined => {\n const result = OAuthClientInformationFullSchema.safeParse(value);\n return result.success ? result.data : undefined;\n};\n\nexport const normalizePersistedAuthState = (\n value: unknown,\n): MCPPersistedAuthState | null => {\n if (!isRecord(value)) return null;\n\n const state: MCPPersistedAuthState = {};\n if (isNonEmptyString(value.token)) state.token = value.token;\n if (isNonEmptyString(value.codeVerifier)) {\n state.codeVerifier = value.codeVerifier;\n }\n\n const tokens = normalizeOAuthTokens(value.tokens);\n if (tokens) state.tokens = tokens;\n\n const clientInformation = normalizeClientInformation(value.clientInformation);\n if (clientInformation) state.clientInformation = clientInformation;\n\n return Object.keys(state).length > 0 ? state : null;\n};\n\nconst useMcpLocalStorage = (opts: McpLocalStorageOptions = {}): MCPStorage => {\n const prefix = opts.keyPrefix ?? \"aui-mcp\";\n const customServersKey = `${prefix}:custom-servers`;\n const authKey = (id: string) => `${prefix}:auth:${id}`;\n const storage = resolveStorage(opts);\n\n const read = <T>(key: string, fallback: T): T => {\n if (!storage) return fallback;\n try {\n const raw = storage.getItem(key);\n if (raw == null) return fallback;\n return JSON.parse(raw) as T;\n } catch {\n return fallback;\n }\n };\n\n const write = (key: string, value: unknown): void => {\n if (!storage) return;\n try {\n storage.setItem(key, JSON.stringify(value));\n } catch {\n // quota or serialization failure — silently drop\n }\n };\n\n const remove = (key: string): void => {\n if (!storage) return;\n try {\n storage.removeItem(key);\n } catch {\n // ignore\n }\n };\n\n return {\n loadCustomServers: async () =>\n normalizeCustomServerRecords(read<unknown>(customServersKey, [])),\n saveCustomServers: async (records) => {\n write(customServersKey, records);\n },\n loadAuthState: async (id) =>\n normalizePersistedAuthState(read<unknown>(authKey(id), null)),\n saveAuthState: async (id, state) => {\n write(authKey(id), state);\n },\n clearAuthState: async (id) => {\n remove(authKey(id));\n },\n };\n};\n\nexport const McpLocalStorage = resource(useMcpLocalStorage);\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@assistant-ui/react-mcp",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.19",
|
|
4
4
|
"description": "MCP server configuration and connection primitives for @assistant-ui",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|
|
@@ -28,11 +28,11 @@
|
|
|
28
28
|
"sideEffects": false,
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
31
|
-
"@radix-ui/react-primitive": "^2.1.
|
|
32
|
-
"@assistant-ui/core": "0.2.
|
|
33
|
-
"@assistant-ui/store": "0.2.
|
|
34
|
-
"@assistant-ui/tap": "0.9.
|
|
35
|
-
"assistant-stream": "0.3.
|
|
31
|
+
"@radix-ui/react-primitive": "^2.1.10",
|
|
32
|
+
"@assistant-ui/core": "0.2.22",
|
|
33
|
+
"@assistant-ui/store": "0.2.21",
|
|
34
|
+
"@assistant-ui/tap": "0.9.5",
|
|
35
|
+
"assistant-stream": "0.3.27"
|
|
36
36
|
},
|
|
37
37
|
"peerDependencies": {
|
|
38
38
|
"@types/react": "*",
|
|
@@ -45,9 +45,9 @@
|
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
47
|
"@types/react": "^19.2.17",
|
|
48
|
-
"react": "^19.2.
|
|
48
|
+
"react": "^19.2.8",
|
|
49
49
|
"vitest": "^4.1.10",
|
|
50
|
-
"@assistant-ui/x-buildutils": "0.0.
|
|
50
|
+
"@assistant-ui/x-buildutils": "0.0.19"
|
|
51
51
|
},
|
|
52
52
|
"publishConfig": {
|
|
53
53
|
"access": "public",
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { createTapRoot, useResource } from "@assistant-ui/tap";
|
|
2
|
+
import type { ClientOutput } from "@assistant-ui/store";
|
|
3
|
+
import { useEffect } from "react";
|
|
2
4
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
5
|
import type { MCPAuthConfig } from "../mcp-scope";
|
|
4
6
|
import type { MCPStorage } from "./storage/types";
|
|
@@ -17,6 +19,7 @@ const mocks = vi.hoisted(() => {
|
|
|
17
19
|
}>
|
|
18
20
|
> = [];
|
|
19
21
|
const finishAuthResults: Array<() => Promise<void>> = [];
|
|
22
|
+
const closeResults: Array<() => Promise<void>> = [];
|
|
20
23
|
|
|
21
24
|
const Client = vi.fn().mockImplementation(function Client(this: any) {
|
|
22
25
|
const index = clients.length;
|
|
@@ -34,7 +37,7 @@ const mocks = vi.hoisted(() => {
|
|
|
34
37
|
.fn()
|
|
35
38
|
.mockImplementation(function StreamableHTTPClientTransport(this: any) {
|
|
36
39
|
const index = transports.length;
|
|
37
|
-
this.close = vi.fn(() => Promise.resolve());
|
|
40
|
+
this.close = vi.fn(() => closeResults[index]?.() ?? Promise.resolve());
|
|
38
41
|
this.finishAuth = vi.fn(
|
|
39
42
|
() => finishAuthResults[index]?.() ?? Promise.resolve(),
|
|
40
43
|
);
|
|
@@ -49,6 +52,7 @@ const mocks = vi.hoisted(() => {
|
|
|
49
52
|
connectResults,
|
|
50
53
|
listToolsResults,
|
|
51
54
|
finishAuthResults,
|
|
55
|
+
closeResults,
|
|
52
56
|
};
|
|
53
57
|
});
|
|
54
58
|
|
|
@@ -102,19 +106,23 @@ const resetMocks = () => {
|
|
|
102
106
|
mocks.connectResults.length = 0;
|
|
103
107
|
mocks.listToolsResults.length = 0;
|
|
104
108
|
mocks.finishAuthResults.length = 0;
|
|
109
|
+
mocks.closeResults.length = 0;
|
|
105
110
|
mocks.Client.mockClear();
|
|
106
111
|
mocks.StreamableHTTPClientTransport.mockClear();
|
|
107
112
|
};
|
|
108
113
|
|
|
109
|
-
const mount = (
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
114
|
+
const mount = (
|
|
115
|
+
props?: {
|
|
116
|
+
auth?: MCPAuthConfig | undefined;
|
|
117
|
+
connectionTimeout?: number | undefined;
|
|
118
|
+
},
|
|
119
|
+
onMount?: (server: ClientOutput<"mcpServer">) => void,
|
|
120
|
+
) => {
|
|
113
121
|
const connectionTimeout =
|
|
114
122
|
props && "connectionTimeout" in props ? props.connectionTimeout : 10_000;
|
|
115
123
|
|
|
116
124
|
return createTapRoot(function Root() {
|
|
117
|
-
|
|
125
|
+
const server = useResource(
|
|
118
126
|
McpServerResource({
|
|
119
127
|
id: "docs",
|
|
120
128
|
kind: "connector",
|
|
@@ -128,6 +136,10 @@ const mount = (props?: {
|
|
|
128
136
|
onRemove: vi.fn(async () => {}),
|
|
129
137
|
}),
|
|
130
138
|
);
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
onMount?.(server);
|
|
141
|
+
}, [server]);
|
|
142
|
+
return server;
|
|
131
143
|
});
|
|
132
144
|
};
|
|
133
145
|
|
|
@@ -247,9 +259,105 @@ describe("McpServerResource connectionTimeout", () => {
|
|
|
247
259
|
});
|
|
248
260
|
});
|
|
249
261
|
|
|
262
|
+
describe("McpServerResource connection lifecycle", () => {
|
|
263
|
+
beforeEach(resetMocks);
|
|
264
|
+
|
|
265
|
+
it("closes a pending connection when the resource unmounts", async () => {
|
|
266
|
+
let resolveConnect!: () => void;
|
|
267
|
+
mocks.connectResults.push(
|
|
268
|
+
() =>
|
|
269
|
+
new Promise<void>((resolve) => {
|
|
270
|
+
resolveConnect = resolve;
|
|
271
|
+
}),
|
|
272
|
+
);
|
|
273
|
+
const root = mount({ connectionTimeout: undefined });
|
|
274
|
+
let didUnmount = false;
|
|
275
|
+
try {
|
|
276
|
+
const connectPromise = root.getValue().connect();
|
|
277
|
+
await waitFor(() => mocks.clients[0]?.connect.mock.calls.length === 1);
|
|
278
|
+
|
|
279
|
+
root.unmount();
|
|
280
|
+
didUnmount = true;
|
|
281
|
+
await flushMacrotask();
|
|
282
|
+
|
|
283
|
+
expect(mocks.transports[0].close).toHaveBeenCalledTimes(1);
|
|
284
|
+
|
|
285
|
+
resolveConnect();
|
|
286
|
+
await connectPromise;
|
|
287
|
+
|
|
288
|
+
expect(mocks.clients[0].listTools).not.toHaveBeenCalled();
|
|
289
|
+
expect(mocks.transports[0].close).toHaveBeenCalledTimes(1);
|
|
290
|
+
} finally {
|
|
291
|
+
if (!didUnmount) root.unmount();
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("waits for pending transports to close before a newer reconnect", async () => {
|
|
296
|
+
let resolveFirstConnect!: () => void;
|
|
297
|
+
let resolveFirstClose!: () => void;
|
|
298
|
+
mocks.connectResults.push(
|
|
299
|
+
() =>
|
|
300
|
+
new Promise<void>((resolve) => {
|
|
301
|
+
resolveFirstConnect = resolve;
|
|
302
|
+
}),
|
|
303
|
+
);
|
|
304
|
+
mocks.closeResults.push(
|
|
305
|
+
() =>
|
|
306
|
+
new Promise<void>((resolve) => {
|
|
307
|
+
resolveFirstClose = resolve;
|
|
308
|
+
}),
|
|
309
|
+
);
|
|
310
|
+
const root = mount({ connectionTimeout: undefined });
|
|
311
|
+
|
|
312
|
+
try {
|
|
313
|
+
const firstConnect = root.getValue().connect();
|
|
314
|
+
await waitFor(() => mocks.clients[0]?.connect.mock.calls.length === 1);
|
|
315
|
+
|
|
316
|
+
const supersededReconnect = root.getValue().connect();
|
|
317
|
+
await waitFor(() => mocks.transports[0]?.close.mock.calls.length === 1);
|
|
318
|
+
|
|
319
|
+
const latestReconnect = root.getValue().connect();
|
|
320
|
+
await flushMacrotask();
|
|
321
|
+
expect(mocks.transports).toHaveLength(1);
|
|
322
|
+
|
|
323
|
+
resolveFirstClose();
|
|
324
|
+
await supersededReconnect;
|
|
325
|
+
await waitFor(() => mocks.clients[1]?.connect.mock.calls.length === 1);
|
|
326
|
+
await latestReconnect;
|
|
327
|
+
|
|
328
|
+
resolveFirstConnect();
|
|
329
|
+
await firstConnect;
|
|
330
|
+
} finally {
|
|
331
|
+
root.unmount();
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
250
336
|
describe("McpServerResource completeAuth", () => {
|
|
251
337
|
beforeEach(resetMocks);
|
|
252
338
|
|
|
339
|
+
it("completes auth across the StrictMode effect replay", async () => {
|
|
340
|
+
let completeAuth: Promise<void> | undefined;
|
|
341
|
+
let started = false;
|
|
342
|
+
const root = mount({ auth: { type: "oauth" } }, (server) => {
|
|
343
|
+
if (started) return;
|
|
344
|
+
started = true;
|
|
345
|
+
completeAuth = server.completeAuth(
|
|
346
|
+
"https://example.com/callback?code=abc",
|
|
347
|
+
);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
try {
|
|
351
|
+
await expect(completeAuth).resolves.toBeUndefined();
|
|
352
|
+
await flushMacrotask();
|
|
353
|
+
|
|
354
|
+
expect(mocks.transports[0].finishAuth).toHaveBeenCalledTimes(1);
|
|
355
|
+
expect(root.getValue().getState().connectionState).toBe("connected");
|
|
356
|
+
} finally {
|
|
357
|
+
root.unmount();
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
|
|
253
361
|
it("rejects when the callback URL has no authorization code", async () => {
|
|
254
362
|
const root = mount();
|
|
255
363
|
|
|
@@ -294,6 +402,71 @@ describe("McpServerResource completeAuth", () => {
|
|
|
294
402
|
root.unmount();
|
|
295
403
|
}
|
|
296
404
|
});
|
|
405
|
+
|
|
406
|
+
it("rejects when the resource unmounts during auth completion", async () => {
|
|
407
|
+
let resolveFinishAuth!: () => void;
|
|
408
|
+
mocks.finishAuthResults.push(
|
|
409
|
+
() =>
|
|
410
|
+
new Promise<void>((resolve) => {
|
|
411
|
+
resolveFinishAuth = resolve;
|
|
412
|
+
}),
|
|
413
|
+
);
|
|
414
|
+
const root = mount({ auth: { type: "oauth" } });
|
|
415
|
+
let didUnmount = false;
|
|
416
|
+
|
|
417
|
+
try {
|
|
418
|
+
const completeAuth = root
|
|
419
|
+
.getValue()
|
|
420
|
+
.completeAuth("https://example.com/callback?code=abc");
|
|
421
|
+
await waitFor(
|
|
422
|
+
() => mocks.transports[0]?.finishAuth.mock.calls.length === 1,
|
|
423
|
+
);
|
|
424
|
+
|
|
425
|
+
root.unmount();
|
|
426
|
+
didUnmount = true;
|
|
427
|
+
resolveFinishAuth();
|
|
428
|
+
|
|
429
|
+
await expect(completeAuth).rejects.toThrow(
|
|
430
|
+
'MCP server "docs" authorization was interrupted before completion.',
|
|
431
|
+
);
|
|
432
|
+
} finally {
|
|
433
|
+
if (!didUnmount) root.unmount();
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it("normalizes late auth failures after unmount", async () => {
|
|
438
|
+
let rejectFinishAuth!: (error: Error) => void;
|
|
439
|
+
mocks.finishAuthResults.push(
|
|
440
|
+
() =>
|
|
441
|
+
new Promise<void>((_, reject) => {
|
|
442
|
+
rejectFinishAuth = reject;
|
|
443
|
+
}),
|
|
444
|
+
);
|
|
445
|
+
const root = mount({ auth: { type: "oauth" } });
|
|
446
|
+
let didUnmount = false;
|
|
447
|
+
|
|
448
|
+
try {
|
|
449
|
+
const completeAuth = root
|
|
450
|
+
.getValue()
|
|
451
|
+
.completeAuth("https://example.com/callback?code=abc");
|
|
452
|
+
await waitFor(
|
|
453
|
+
() => mocks.transports[0]?.finishAuth.mock.calls.length === 1,
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
root.unmount();
|
|
457
|
+
didUnmount = true;
|
|
458
|
+
const finishAuthError = new Error("Connection closed");
|
|
459
|
+
rejectFinishAuth(finishAuthError);
|
|
460
|
+
|
|
461
|
+
await expect(completeAuth).rejects.toMatchObject({
|
|
462
|
+
message:
|
|
463
|
+
'MCP server "docs" authorization was interrupted before completion.',
|
|
464
|
+
cause: finishAuthError,
|
|
465
|
+
});
|
|
466
|
+
} finally {
|
|
467
|
+
if (!didUnmount) root.unmount();
|
|
468
|
+
}
|
|
469
|
+
});
|
|
297
470
|
});
|
|
298
471
|
|
|
299
472
|
describe("McpServerResource resource methods", () => {
|
|
@@ -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,14 +280,25 @@ 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
|
-
await closeTransport();
|
|
224
297
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
298
|
+
if (!isCurrentConnection(generation))
|
|
299
|
+
throw createInterruptedAuthError(error);
|
|
300
|
+
|
|
301
|
+
await closeTransports();
|
|
225
302
|
setLastError({
|
|
226
303
|
message: error.message,
|
|
227
304
|
});
|
|
@@ -255,14 +332,22 @@ const useMcpServerResource = (
|
|
|
255
332
|
|
|
256
333
|
// Auto-connect on mount when usable auth exists.
|
|
257
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;
|
|
258
340
|
const signal = { cancelled: false };
|
|
259
341
|
void tryAutoConnect(signal);
|
|
260
342
|
return () => {
|
|
343
|
+
mountedRef.current = false;
|
|
261
344
|
signal.cancelled = true;
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
+
});
|
|
266
351
|
};
|
|
267
352
|
}, []);
|
|
268
353
|
|
|
@@ -39,6 +39,40 @@ describe("normalizeCustomServerRecords", () => {
|
|
|
39
39
|
).toEqual([validRecord]);
|
|
40
40
|
});
|
|
41
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
|
+
|
|
42
76
|
it("accepts persisted bearer and oauth auth configs", () => {
|
|
43
77
|
expect(
|
|
44
78
|
normalizeCustomServerRecords([
|
|
@@ -165,6 +199,28 @@ const loadStorage = (storage: Storage) =>
|
|
|
165
199
|
);
|
|
166
200
|
}).getValue();
|
|
167
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
|
+
|
|
168
224
|
describe("McpLocalStorage auth state", () => {
|
|
169
225
|
it("normalizes loaded auth state from localStorage", async () => {
|
|
170
226
|
const storage = createStorage();
|