@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.
Files changed (37) hide show
  1. package/dist/primitives/addForm/McpAddFormAuthSelect.js.map +1 -1
  2. package/dist/primitives/addForm/McpAddFormCancel.d.ts.map +1 -1
  3. package/dist/primitives/addForm/McpAddFormError.d.ts.map +1 -1
  4. package/dist/primitives/addForm/McpAddFormError.js.map +1 -1
  5. package/dist/primitives/addForm/McpAddFormRoot.d.ts.map +1 -1
  6. package/dist/primitives/addForm/McpAddFormRoot.js.map +1 -1
  7. package/dist/primitives/addForm/McpAddFormSubmit.d.ts.map +1 -1
  8. package/dist/primitives/manager/McpManagerAddCustomTrigger.d.ts.map +1 -1
  9. package/dist/primitives/manager/McpManagerConnectors.js.map +1 -1
  10. package/dist/primitives/manager/McpManagerCustomServers.js.map +1 -1
  11. package/dist/primitives/manager/McpManagerRoot.d.ts.map +1 -1
  12. package/dist/primitives/server/McpServerConnectButton.d.ts.map +1 -1
  13. package/dist/primitives/server/McpServerDisconnectButton.d.ts.map +1 -1
  14. package/dist/primitives/server/McpServerError.d.ts.map +1 -1
  15. package/dist/primitives/server/McpServerError.js.map +1 -1
  16. package/dist/primitives/server/McpServerIcon.d.ts.map +1 -1
  17. package/dist/primitives/server/McpServerName.d.ts.map +1 -1
  18. package/dist/primitives/server/McpServerName.js.map +1 -1
  19. package/dist/primitives/server/McpServerOAuthLink.d.ts.map +1 -1
  20. package/dist/primitives/server/McpServerRemoveButton.d.ts.map +1 -1
  21. package/dist/primitives/server/McpServerRoot.d.ts.map +1 -1
  22. package/dist/primitives/server/McpServerStatus.d.ts.map +1 -1
  23. package/dist/primitives/server/McpServerStatus.js.map +1 -1
  24. package/dist/primitives/server/McpServerToolName.d.ts.map +1 -1
  25. package/dist/primitives/server/McpServerToolName.js.map +1 -1
  26. package/dist/primitives/server/McpServerTools.js.map +1 -1
  27. package/dist/resources/McpServerResource.d.ts.map +1 -1
  28. package/dist/resources/McpServerResource.js +89 -31
  29. package/dist/resources/McpServerResource.js.map +1 -1
  30. package/dist/resources/storage/McpLocalStorage.d.ts.map +1 -1
  31. package/dist/resources/storage/McpLocalStorage.js +13 -2
  32. package/dist/resources/storage/McpLocalStorage.js.map +1 -1
  33. package/package.json +8 -8
  34. package/src/resources/McpServerResource.test.ts +179 -6
  35. package/src/resources/McpServerResource.ts +114 -29
  36. package/src/resources/storage/McpLocalStorage.test.ts +56 -0
  37. 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.filter(isCustomServerRecord);
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.18",
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.7",
32
- "@assistant-ui/core": "0.2.21",
33
- "@assistant-ui/store": "0.2.20",
34
- "@assistant-ui/tap": "0.9.4",
35
- "assistant-stream": "0.3.26"
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.7",
48
+ "react": "^19.2.8",
49
49
  "vitest": "^4.1.10",
50
- "@assistant-ui/x-buildutils": "0.0.18"
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 = (props?: {
110
- auth?: MCPAuthConfig | undefined;
111
- connectionTimeout?: number | undefined;
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
- return useResource(
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 (transport: StreamableHTTPClientTransport) => {
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 closeTransport();
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
- try {
187
- await transport.close();
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 closeTransport();
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
- transportRef.current = transport;
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
- const t = transportRef.current;
263
- transportRef.current = null;
264
- clientRef.current = null;
265
- if (t) t.close().catch(() => {});
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();