@assistant-ui/react-mcp 0.1.20 → 0.1.22

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 (85) hide show
  1. package/LICENSE +1 -1
  2. package/dist/auth/createOAuthProvider.d.ts.map +1 -1
  3. package/dist/auth/createOAuthProvider.js +7 -1
  4. package/dist/auth/createOAuthProvider.js.map +1 -1
  5. package/dist/context/McpConnectorByIndexProvider.js.map +1 -1
  6. package/dist/context/McpCustomServerByIndexProvider.js.map +1 -1
  7. package/dist/context/McpServerByIdProvider.js.map +1 -1
  8. package/dist/hooks/useMcpOAuthCallback.js.map +1 -1
  9. package/dist/primitives/addForm/McpAddFormAuthFields.js.map +1 -1
  10. package/dist/primitives/addForm/McpAddFormAuthSelect.js.map +1 -1
  11. package/dist/primitives/addForm/McpAddFormBearerTokenField.js.map +1 -1
  12. package/dist/primitives/addForm/McpAddFormCancel.js.map +1 -1
  13. package/dist/primitives/addForm/McpAddFormError.js.map +1 -1
  14. package/dist/primitives/addForm/McpAddFormNameField.js.map +1 -1
  15. package/dist/primitives/addForm/McpAddFormRoot.d.ts.map +1 -1
  16. package/dist/primitives/addForm/McpAddFormRoot.js +14 -5
  17. package/dist/primitives/addForm/McpAddFormRoot.js.map +1 -1
  18. package/dist/primitives/addForm/McpAddFormScopesField.js.map +1 -1
  19. package/dist/primitives/addForm/McpAddFormSubmit.js.map +1 -1
  20. package/dist/primitives/addForm/McpAddFormUrlField.js.map +1 -1
  21. package/dist/primitives/elicitation/McpElicitationPrimitiveAccept.js.map +1 -1
  22. package/dist/primitives/elicitation/McpElicitationPrimitiveCancel.js.map +1 -1
  23. package/dist/primitives/elicitation/McpElicitationPrimitiveDecline.js.map +1 -1
  24. package/dist/primitives/elicitation/McpElicitationPrimitiveError.js.map +1 -1
  25. package/dist/primitives/elicitation/McpElicitationPrimitiveFields.js.map +1 -1
  26. package/dist/primitives/elicitation/McpElicitationPrimitiveItems.js.map +1 -1
  27. package/dist/primitives/elicitation/McpElicitationPrimitiveMessage.js.map +1 -1
  28. package/dist/primitives/elicitation/McpElicitationPrimitiveRoot.js.map +1 -1
  29. package/dist/primitives/manager/McpManagerAddCustomTrigger.js.map +1 -1
  30. package/dist/primitives/manager/McpManagerRoot.js.map +1 -1
  31. package/dist/primitives/server/McpServerConnectButton.js.map +1 -1
  32. package/dist/primitives/server/McpServerDisconnectButton.js.map +1 -1
  33. package/dist/primitives/server/McpServerError.js.map +1 -1
  34. package/dist/primitives/server/McpServerIcon.js.map +1 -1
  35. package/dist/primitives/server/McpServerName.js.map +1 -1
  36. package/dist/primitives/server/McpServerOAuthLink.js.map +1 -1
  37. package/dist/primitives/server/McpServerRemoveButton.js.map +1 -1
  38. package/dist/primitives/server/McpServerRoot.js.map +1 -1
  39. package/dist/primitives/server/McpServerStatus.js.map +1 -1
  40. package/dist/primitives/server/McpServerToolName.js.map +1 -1
  41. package/dist/primitives/server/McpServerTools.js.map +1 -1
  42. package/dist/resources/McpCustomServerPersistence.d.ts +17 -0
  43. package/dist/resources/McpCustomServerPersistence.d.ts.map +1 -0
  44. package/dist/resources/McpCustomServerPersistence.js +192 -0
  45. package/dist/resources/McpCustomServerPersistence.js.map +1 -0
  46. package/dist/resources/McpElicitationLifecycle.d.ts +12 -0
  47. package/dist/resources/McpElicitationLifecycle.d.ts.map +1 -0
  48. package/dist/resources/McpElicitationLifecycle.js +144 -0
  49. package/dist/resources/McpElicitationLifecycle.js.map +1 -0
  50. package/dist/resources/McpManagerResource.d.ts.map +1 -1
  51. package/dist/resources/McpManagerResource.js +28 -205
  52. package/dist/resources/McpManagerResource.js.map +1 -1
  53. package/dist/resources/McpServerResource.d.ts.map +1 -1
  54. package/dist/resources/McpServerResource.js +24 -101
  55. package/dist/resources/McpServerResource.js.map +1 -1
  56. package/dist/resources/storage/McpLocalStorage.d.ts +1 -4
  57. package/dist/resources/storage/McpLocalStorage.d.ts.map +1 -1
  58. package/dist/resources/storage/McpLocalStorage.js +1 -100
  59. package/dist/resources/storage/McpLocalStorage.js.map +1 -1
  60. package/dist/resources/storage/McpStoredDataNormalization.d.ts +5 -0
  61. package/dist/resources/storage/McpStoredDataNormalization.d.ts.map +1 -0
  62. package/dist/resources/storage/McpStoredDataNormalization.js +95 -0
  63. package/dist/resources/storage/McpStoredDataNormalization.js.map +1 -0
  64. package/dist/utils/serverUrl.d.ts +1 -0
  65. package/dist/utils/serverUrl.d.ts.map +1 -1
  66. package/dist/utils/serverUrl.js +15 -1
  67. package/dist/utils/serverUrl.js.map +1 -1
  68. package/package.json +15 -11
  69. package/src/auth/createOAuthProvider.test.ts +30 -1
  70. package/src/auth/createOAuthProvider.ts +12 -4
  71. package/src/primitives/addForm/McpAddFormRoot.test.tsx +66 -0
  72. package/src/primitives/addForm/McpAddFormRoot.tsx +11 -1
  73. package/src/resources/McpCustomServerPersistence.test.ts +56 -0
  74. package/src/resources/McpCustomServerPersistence.ts +233 -0
  75. package/src/resources/McpElicitationLifecycle.test.ts +83 -0
  76. package/src/resources/McpElicitationLifecycle.ts +156 -0
  77. package/src/resources/McpManagerResource.test.ts +36 -0
  78. package/src/resources/McpManagerResource.ts +25 -248
  79. package/src/resources/McpServerResource.test.ts +123 -10
  80. package/src/resources/McpServerResource.ts +29 -149
  81. package/src/resources/storage/McpLocalStorage.ts +9 -209
  82. package/src/resources/storage/McpStoredDataNormalization.test.ts +24 -0
  83. package/src/resources/storage/McpStoredDataNormalization.ts +201 -0
  84. package/src/utils/serverUrl.test.ts +30 -0
  85. package/src/utils/serverUrl.ts +26 -0
@@ -7,8 +7,6 @@ import {
7
7
  StreamableHTTPClientTransport,
8
8
  UnauthorizedError,
9
9
  type ClientOptions,
10
- type ElicitRequest,
11
- type ElicitResult,
12
10
  type StreamableHTTPClientTransportOptions,
13
11
  } from "@modelcontextprotocol/client";
14
12
  import {
@@ -21,20 +19,18 @@ import { assertValidServerId } from "../utils/serverId";
21
19
  import {
22
20
  hasPersistedCredentials,
23
21
  isAuthStateForServerUrl,
22
+ isSecureNetworkUrl,
24
23
  } from "../utils/serverUrl";
25
- import { validateElicitationContent } from "./validateElicitationContent";
26
24
  import type { MCPStorage } from "./storage/types";
27
25
  import type {
28
26
  MCPAuthConfig,
29
27
  MCPConnectionState,
30
- MCPElicitation,
31
- MCPElicitationResponse,
32
28
  MCPServerKind,
33
29
  MCPServerState,
34
30
  MCPToolInfo,
35
31
  } from "../mcp-scope";
36
- import { createMcpId } from "../utils/createMcpId";
37
32
  import { beginMcpServerRemovalFence } from "./McpServerRemovalFence";
33
+ import { useMcpElicitationLifecycle } from "./McpElicitationLifecycle";
38
34
 
39
35
  export type McpServerResourceProps = {
40
36
  id: string;
@@ -96,10 +92,12 @@ const useMcpServerResourceInstance = (
96
92
  const [tools, setTools] = useState<MCPToolInfo[]>([]);
97
93
  const [lastError, setLastError] = useState<{ message: string } | null>(null);
98
94
  const [authorizationUrl, setAuthorizationUrl] = useState<string | null>(null);
99
- const [pendingElicitations, setPendingElicitations] = useState<
100
- MCPElicitation[]
101
- >([]);
102
-
95
+ const {
96
+ pendingElicitations,
97
+ cancelPendingElicitations,
98
+ requestElicitation,
99
+ answerElicitation,
100
+ } = useMcpElicitationLifecycle();
103
101
  const clientRef = useRef<Client | null>(null);
104
102
  const transportRef = useRef<StreamableHTTPClientTransport | null>(null);
105
103
  const pendingTransportRef = useRef<StreamableHTTPClientTransport | null>(
@@ -114,17 +112,6 @@ const useMcpServerResourceInstance = (
114
112
  promise: Promise<void>;
115
113
  resolve: () => void;
116
114
  } | null>(null);
117
- const elicitationResolversRef = useRef(
118
- new Map<
119
- string,
120
- {
121
- resolve: (result: ElicitResult) => void;
122
- signal: AbortSignal;
123
- onAbort: () => void;
124
- requestedSchema: unknown;
125
- }
126
- >(),
127
- );
128
115
  const pendingDisposalRef = useRef<{ cancelled: boolean } | null>(null);
129
116
  const mountedRef = useRef(true);
130
117
 
@@ -154,37 +141,6 @@ const useMcpServerResourceInstance = (
154
141
  await closeQueuedTransports(transport ? [transport] : []);
155
142
  };
156
143
 
157
- const resolvePendingElicitation = (id: string, result: ElicitResult) => {
158
- const entry = elicitationResolversRef.current.get(id);
159
- if (!entry) return false;
160
- elicitationResolversRef.current.delete(id);
161
- entry.signal.removeEventListener("abort", entry.onAbort);
162
- setPendingElicitations((current) =>
163
- current.filter((elicitation) => elicitation.id !== id),
164
- );
165
- entry.resolve(result);
166
- return true;
167
- };
168
-
169
- const setPendingElicitationError = (
170
- id: string,
171
- error: NonNullable<MCPElicitation["error"]>,
172
- ) => {
173
- if (!elicitationResolversRef.current.has(id)) return false;
174
- setPendingElicitations((current) =>
175
- current.map((elicitation) =>
176
- elicitation.id === id ? { ...elicitation, error } : elicitation,
177
- ),
178
- );
179
- return true;
180
- };
181
-
182
- const cancelPendingElicitations = () => {
183
- for (const [id] of elicitationResolversRef.current) {
184
- resolvePendingElicitation(id, { action: "cancel" });
185
- }
186
- };
187
-
188
144
  const detachTransports = () => {
189
145
  cancelPendingElicitations();
190
146
  const pendingTransport = pendingTransportRef.current;
@@ -258,6 +214,11 @@ const useMcpServerResourceInstance = (
258
214
 
259
215
  const buildTransport = useEffectEvent(
260
216
  async (generation: number): Promise<StreamableHTTPClientTransport> => {
217
+ if (props.auth.type !== "none" && !isSecureNetworkUrl(props.url)) {
218
+ throw new Error(
219
+ `Authenticated MCP server "${props.id}" must use HTTPS or loopback HTTP.`,
220
+ );
221
+ }
261
222
  if (props.auth.type === "oauth") {
262
223
  const generationOwner = { current: generation };
263
224
  const authProvider = createOAuthProvider({
@@ -267,9 +228,18 @@ const useMcpServerResourceInstance = (
267
228
  storage: props.storage,
268
229
  redirectUri: props.redirectUri,
269
230
  onAuthorizationUrl: (url) => {
270
- if (isCurrentConnection(generationOwner.current)) {
271
- setAuthorizationUrl(url.toString());
231
+ if (!isCurrentConnection(generationOwner.current)) return;
232
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
233
+ throw new Error(
234
+ `Unsupported MCP OAuth authorization URL protocol: ${url.protocol}`,
235
+ );
272
236
  }
237
+ if (!isSecureNetworkUrl(url.toString())) {
238
+ throw new Error(
239
+ "MCP OAuth authorization URL must use HTTPS or loopback HTTP.",
240
+ );
241
+ }
242
+ setAuthorizationUrl(url.toString());
273
243
  },
274
244
  });
275
245
  const transport = new StreamableHTTPClientTransport(
@@ -375,49 +345,10 @@ const useMcpServerResourceInstance = (
375
345
  clientOptions,
376
346
  );
377
347
  if (props.elicitation !== false) {
378
- client.setRequestHandler(
379
- "elicitation/create",
380
- (request: ElicitRequest, context): Promise<ElicitResult> => {
381
- if (!isCurrentConnection(generation)) {
382
- return Promise.resolve({ action: "cancel" });
383
- }
384
- if (!("requestedSchema" in request.params)) {
385
- return Promise.resolve({ action: "cancel" });
386
- }
387
- const { message, requestedSchema } = request.params;
388
-
389
- const id = createMcpId();
390
- const promise = new Promise<ElicitResult>((resolve) => {
391
- const onAbort = () => {
392
- resolvePendingElicitation(id, { action: "cancel" });
393
- };
394
- elicitationResolversRef.current.set(id, {
395
- resolve,
396
- signal: context.mcpReq.signal,
397
- onAbort,
398
- requestedSchema,
399
- });
400
- });
401
- setPendingElicitations((current) => [
402
- ...current,
403
- {
404
- id,
405
- message,
406
- requestedSchema,
407
- },
408
- ]);
409
- const entry = elicitationResolversRef.current.get(id);
410
- if (entry) {
411
- if (context.mcpReq.signal.aborted) {
412
- entry.onAbort();
413
- } else {
414
- context.mcpReq.signal.addEventListener("abort", entry.onAbort, {
415
- once: true,
416
- });
417
- }
418
- }
419
- return promise;
420
- },
348
+ client.setRequestHandler("elicitation/create", (request, context) =>
349
+ requestElicitation(request, context.mcpReq.signal, () =>
350
+ isCurrentConnection(generation),
351
+ ),
421
352
  );
422
353
  }
423
354
  const startedAt = Date.now();
@@ -721,58 +652,7 @@ const useMcpServerResourceInstance = (
721
652
  return await client.readResource({ uri });
722
653
  },
723
654
  completeAuth: doCompleteAuth,
724
- answerElicitation: (
725
- id: string,
726
- response: MCPElicitationResponse,
727
- ): readonly { property: string; message: string }[] | undefined => {
728
- if (response.action === "accept") {
729
- const entry = elicitationResolversRef.current.get(id);
730
- if (!entry) return undefined;
731
-
732
- if (
733
- typeof response.content !== "object" ||
734
- response.content === null ||
735
- Array.isArray(response.content)
736
- ) {
737
- const errors = [
738
- {
739
- property: "content",
740
- message: "Response content must be an object.",
741
- },
742
- ];
743
- setPendingElicitationError(id, {
744
- message: "Invalid elicitation content: content.",
745
- properties: ["content"],
746
- });
747
- return errors;
748
- }
749
-
750
- const errors = validateElicitationContent(
751
- entry.requestedSchema,
752
- response.content,
753
- );
754
- if (errors.length > 0) {
755
- const properties = [
756
- ...new Set(errors.map((error) => error.property)),
757
- ];
758
- setPendingElicitationError(id, {
759
- message: `Invalid elicitation content: ${properties.join(", ")}.`,
760
- properties,
761
- });
762
- return errors;
763
- }
764
-
765
- const result: ElicitResult = {
766
- action: "accept",
767
- content: response.content as ElicitResult["content"],
768
- };
769
- resolvePendingElicitation(id, result);
770
- return undefined;
771
- }
772
-
773
- resolvePendingElicitation(id, { action: response.action });
774
- return undefined;
775
- },
655
+ answerElicitation,
776
656
  };
777
657
  };
778
658
 
@@ -1,16 +1,15 @@
1
1
  import { resource } from "@assistant-ui/tap";
2
2
  import { useMemo } from "react";
3
- import {
4
- OAuthMetadataSchema,
5
- OAuthClientInformationFullSchema,
6
- OAuthProtectedResourceMetadataSchema,
7
- OAuthTokensSchema,
8
- } from "@modelcontextprotocol/core";
9
- import { normalizeMcpServerUrl } from "../../utils/serverUrl";
10
- import type { MCPAuthConfig, MCPCustomServerRecord } from "../../mcp-scope";
11
- import type { MCPPersistedAuthState } from "../../auth/types";
12
- import { assertValidServerId } from "../../utils/serverId";
13
3
  import type { MCPStorage } from "./types";
4
+ import {
5
+ normalizeCustomServerRecords,
6
+ normalizePersistedAuthState,
7
+ } from "./McpStoredDataNormalization";
8
+
9
+ export {
10
+ normalizeCustomServerRecords,
11
+ normalizePersistedAuthState,
12
+ } from "./McpStoredDataNormalization";
14
13
 
15
14
  export type McpLocalStorageOptions = {
16
15
  /** Namespace prefix for keys. Default "aui-mcp". */
@@ -38,205 +37,6 @@ function resolveStorage(opts: McpLocalStorageOptions): Storage | null {
38
37
  return null;
39
38
  }
40
39
 
41
- const isRecord = (value: unknown): value is Record<string, unknown> =>
42
- typeof value === "object" && value !== null && !Array.isArray(value);
43
-
44
- const isOptionalString = (value: unknown): value is string | undefined =>
45
- value === undefined || typeof value === "string";
46
-
47
- const isNonEmptyString = (value: unknown): value is string =>
48
- typeof value === "string" && value.trim().length > 0;
49
-
50
- const isOptionalNonEmptyString = (
51
- value: unknown,
52
- ): value is string | undefined =>
53
- value === undefined || isNonEmptyString(value);
54
-
55
- const isOptionalStringArray = (value: unknown): value is string[] | undefined =>
56
- value === undefined ||
57
- (Array.isArray(value) && value.every((item) => typeof item === "string"));
58
-
59
- const isOptionalConnectionTimeout = (
60
- value: unknown,
61
- ): value is number | undefined =>
62
- value === undefined ||
63
- (typeof value === "number" && Number.isFinite(value) && value >= 0);
64
-
65
- const isValidServerId = (id: string): boolean => {
66
- try {
67
- assertValidServerId(id);
68
- return true;
69
- } catch {
70
- return false;
71
- }
72
- };
73
-
74
- const isMCPAuthConfig = (auth: unknown): auth is MCPAuthConfig => {
75
- if (!isRecord(auth)) return false;
76
-
77
- switch (auth.type) {
78
- case "none":
79
- return true;
80
- case "bearer":
81
- return isOptionalNonEmptyString(auth.token);
82
- case "oauth":
83
- return (
84
- isOptionalStringArray(auth.scopes) &&
85
- isOptionalString(auth.authorizationEndpoint) &&
86
- isOptionalString(auth.tokenEndpoint) &&
87
- isOptionalString(auth.registrationEndpoint) &&
88
- isOptionalString(auth.clientId) &&
89
- isOptionalString(auth.clientSecret)
90
- );
91
- default:
92
- return false;
93
- }
94
- };
95
-
96
- const isCustomServerRecord = (
97
- value: unknown,
98
- ): value is MCPCustomServerRecord => {
99
- if (!isRecord(value)) return false;
100
- if (typeof value.id !== "string" || !isValidServerId(value.id)) {
101
- return false;
102
- }
103
- return (
104
- isNonEmptyString(value.name) &&
105
- isNonEmptyString(value.url) &&
106
- Number.isFinite(value.createdAt) &&
107
- isMCPAuthConfig(value.auth) &&
108
- isOptionalConnectionTimeout(value.connectionTimeout)
109
- );
110
- };
111
-
112
- const normalizeCustomServerRecord = (
113
- value: unknown,
114
- ): MCPCustomServerRecord | null => {
115
- if (isCustomServerRecord(value)) return value;
116
- if (!isRecord(value)) return null;
117
-
118
- const record = { ...value };
119
- delete record.connectionTimeout;
120
- return isCustomServerRecord(record) ? record : null;
121
- };
122
-
123
- export const normalizeCustomServerRecords = (
124
- value: unknown,
125
- ): MCPCustomServerRecord[] => {
126
- if (!Array.isArray(value)) return [];
127
- return value.flatMap((item) => {
128
- const record = normalizeCustomServerRecord(item);
129
- return record === null ? [] : [record];
130
- });
131
- };
132
-
133
- const normalizeOAuthTokens = (
134
- value: unknown,
135
- ): MCPPersistedAuthState["tokens"] | undefined => {
136
- const result = OAuthTokensSchema.safeParse(value);
137
- return result.success ? result.data : undefined;
138
- };
139
-
140
- const normalizeClientInformation = (
141
- value: unknown,
142
- ): MCPPersistedAuthState["clientInformation"] | undefined => {
143
- const result = OAuthClientInformationFullSchema.safeParse(value);
144
- return result.success ? result.data : undefined;
145
- };
146
-
147
- const isSecureNetworkUrl = (value: unknown): value is string => {
148
- if (!isNonEmptyString(value)) return false;
149
- try {
150
- const url = new URL(value);
151
- const isIpv4Loopback = /^127(?:\.\d{1,3}){3}$/.test(url.hostname);
152
- return (
153
- url.protocol === "https:" ||
154
- (url.protocol === "http:" &&
155
- (url.hostname === "localhost" ||
156
- url.hostname.endsWith(".localhost") ||
157
- isIpv4Loopback ||
158
- url.hostname === "[::1]"))
159
- );
160
- } catch {
161
- return false;
162
- }
163
- };
164
-
165
- const isMcpServerUrl = (value: unknown): value is string => {
166
- if (typeof value !== "string") return false;
167
- try {
168
- const url = new URL(value);
169
- return url.protocol === "https:" || url.protocol === "http:";
170
- } catch {
171
- return false;
172
- }
173
- };
174
-
175
- const normalizeDiscoveryState = (
176
- value: unknown,
177
- ): MCPPersistedAuthState["discoveryState"] | undefined => {
178
- if (!isRecord(value) || !isSecureNetworkUrl(value.authorizationServerUrl)) {
179
- return undefined;
180
- }
181
-
182
- // A malformed optional field is dropped alone: keeping the validated
183
- // authorization server URL preserves the redirect-time binding, and the SDK
184
- // re-discovers whatever metadata is missing.
185
- const state: NonNullable<MCPPersistedAuthState["discoveryState"]> = {
186
- authorizationServerUrl: value.authorizationServerUrl,
187
- };
188
-
189
- if (isSecureNetworkUrl(value.resourceMetadataUrl)) {
190
- state.resourceMetadataUrl = value.resourceMetadataUrl;
191
- }
192
-
193
- const metadata = OAuthMetadataSchema.safeParse(
194
- value.authorizationServerMetadata,
195
- );
196
- if (metadata.success) state.authorizationServerMetadata = metadata.data;
197
-
198
- const resourceMetadata = OAuthProtectedResourceMetadataSchema.safeParse(
199
- value.resourceMetadata,
200
- );
201
- if (resourceMetadata.success) state.resourceMetadata = resourceMetadata.data;
202
-
203
- return state;
204
- };
205
-
206
- export const normalizePersistedAuthState = (
207
- value: unknown,
208
- ): MCPPersistedAuthState | null => {
209
- if (!isRecord(value)) return null;
210
- if ("serverUrl" in value && !isMcpServerUrl(value.serverUrl)) return null;
211
-
212
- const state: MCPPersistedAuthState = {};
213
- if (isMcpServerUrl(value.serverUrl)) {
214
- state.serverUrl = normalizeMcpServerUrl(value.serverUrl);
215
- }
216
- if (isNonEmptyString(value.token)) state.token = value.token;
217
- if (isNonEmptyString(value.tokensClientId)) {
218
- state.tokensClientId = value.tokensClientId;
219
- }
220
- if (isNonEmptyString(value.codeVerifier)) {
221
- state.codeVerifier = value.codeVerifier;
222
- }
223
- if (isNonEmptyString(value.state)) state.state = value.state;
224
-
225
- const tokens = normalizeOAuthTokens(value.tokens);
226
- if (tokens) state.tokens = tokens;
227
-
228
- const clientInformation = normalizeClientInformation(value.clientInformation);
229
- if (clientInformation) state.clientInformation = clientInformation;
230
- if (value.clientInformationSource === "registered") {
231
- state.clientInformationSource = value.clientInformationSource;
232
- }
233
-
234
- const discoveryState = normalizeDiscoveryState(value.discoveryState);
235
- if (discoveryState) state.discoveryState = discoveryState;
236
-
237
- return Object.keys(state).length > 0 ? state : null;
238
- };
239
-
240
40
  const useMcpLocalStorage = (opts: McpLocalStorageOptions = {}): MCPStorage => {
241
41
  const prefix = opts.keyPrefix ?? "aui-mcp";
242
42
  const storage = resolveStorage(opts);
@@ -0,0 +1,24 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ normalizeCustomServerRecords,
4
+ normalizePersistedAuthState,
5
+ } from "./McpStoredDataNormalization";
6
+
7
+ describe("stored data normalization", () => {
8
+ it("rejects unsafe persisted server ids and URLs", () => {
9
+ expect(
10
+ normalizeCustomServerRecords([
11
+ {
12
+ id: "docs__tool",
13
+ name: "Docs",
14
+ url: "https://example.com/mcp",
15
+ auth: { type: "none" },
16
+ createdAt: 1,
17
+ },
18
+ ]),
19
+ ).toEqual([]);
20
+ expect(
21
+ normalizePersistedAuthState({ serverUrl: "javascript:alert(1)" }),
22
+ ).toBeNull();
23
+ });
24
+ });
@@ -0,0 +1,201 @@
1
+ import {
2
+ OAuthMetadataSchema,
3
+ OAuthClientInformationFullSchema,
4
+ OAuthProtectedResourceMetadataSchema,
5
+ OAuthTokensSchema,
6
+ } from "@modelcontextprotocol/core";
7
+ import {
8
+ isSecureNetworkUrl,
9
+ normalizeMcpServerUrl,
10
+ } from "../../utils/serverUrl";
11
+ import type { MCPAuthConfig, MCPCustomServerRecord } from "../../mcp-scope";
12
+ import type { MCPPersistedAuthState } from "../../auth/types";
13
+ import { assertValidServerId } from "../../utils/serverId";
14
+
15
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
16
+ typeof value === "object" && value !== null && !Array.isArray(value);
17
+
18
+ const isOptionalString = (value: unknown): value is string | undefined =>
19
+ value === undefined || typeof value === "string";
20
+
21
+ const isNonEmptyString = (value: unknown): value is string =>
22
+ typeof value === "string" && value.trim().length > 0;
23
+
24
+ const isOptionalNonEmptyString = (
25
+ value: unknown,
26
+ ): value is string | undefined =>
27
+ value === undefined || isNonEmptyString(value);
28
+
29
+ const isOptionalStringArray = (value: unknown): value is string[] | undefined =>
30
+ value === undefined ||
31
+ (Array.isArray(value) && value.every((item) => typeof item === "string"));
32
+
33
+ const isOptionalConnectionTimeout = (
34
+ value: unknown,
35
+ ): value is number | undefined =>
36
+ value === undefined ||
37
+ (typeof value === "number" && Number.isFinite(value) && value >= 0);
38
+
39
+ const isValidServerId = (id: string): boolean => {
40
+ try {
41
+ assertValidServerId(id);
42
+ return true;
43
+ } catch {
44
+ return false;
45
+ }
46
+ };
47
+
48
+ const isMCPAuthConfig = (auth: unknown): auth is MCPAuthConfig => {
49
+ if (!isRecord(auth)) return false;
50
+
51
+ switch (auth.type) {
52
+ case "none":
53
+ return true;
54
+ case "bearer":
55
+ return isOptionalNonEmptyString(auth.token);
56
+ case "oauth":
57
+ return (
58
+ isOptionalStringArray(auth.scopes) &&
59
+ isOptionalString(auth.authorizationEndpoint) &&
60
+ isOptionalString(auth.tokenEndpoint) &&
61
+ isOptionalString(auth.registrationEndpoint) &&
62
+ isOptionalString(auth.clientId) &&
63
+ isOptionalString(auth.clientSecret)
64
+ );
65
+ default:
66
+ return false;
67
+ }
68
+ };
69
+
70
+ const isCustomServerRecord = (
71
+ value: unknown,
72
+ ): value is MCPCustomServerRecord => {
73
+ if (!isRecord(value)) return false;
74
+ if (typeof value.id !== "string" || !isValidServerId(value.id)) {
75
+ return false;
76
+ }
77
+ return (
78
+ isNonEmptyString(value.name) &&
79
+ isNonEmptyString(value.url) &&
80
+ Number.isFinite(value.createdAt) &&
81
+ isMCPAuthConfig(value.auth) &&
82
+ isOptionalConnectionTimeout(value.connectionTimeout)
83
+ );
84
+ };
85
+
86
+ const normalizeCustomServerRecord = (
87
+ value: unknown,
88
+ ): MCPCustomServerRecord | null => {
89
+ if (isCustomServerRecord(value)) return value;
90
+ if (!isRecord(value)) return null;
91
+
92
+ const record = { ...value };
93
+ delete record.connectionTimeout;
94
+ return isCustomServerRecord(record) ? record : null;
95
+ };
96
+
97
+ export const normalizeCustomServerRecords = (
98
+ value: unknown,
99
+ ): MCPCustomServerRecord[] => {
100
+ if (!Array.isArray(value)) return [];
101
+ return value.flatMap((item) => {
102
+ const record = normalizeCustomServerRecord(item);
103
+ return record === null ? [] : [record];
104
+ });
105
+ };
106
+
107
+ const normalizeOAuthTokens = (
108
+ value: unknown,
109
+ ): MCPPersistedAuthState["tokens"] | undefined => {
110
+ const result = OAuthTokensSchema.safeParse(value);
111
+ return result.success ? result.data : undefined;
112
+ };
113
+
114
+ const normalizeClientInformation = (
115
+ value: unknown,
116
+ ): MCPPersistedAuthState["clientInformation"] | undefined => {
117
+ const result = OAuthClientInformationFullSchema.safeParse(value);
118
+ return result.success ? result.data : undefined;
119
+ };
120
+
121
+ const isMcpServerUrl = (value: unknown): value is string => {
122
+ if (typeof value !== "string") return false;
123
+ try {
124
+ const url = new URL(value);
125
+ return url.protocol === "https:" || url.protocol === "http:";
126
+ } catch {
127
+ return false;
128
+ }
129
+ };
130
+
131
+ const normalizeDiscoveryState = (
132
+ value: unknown,
133
+ ): MCPPersistedAuthState["discoveryState"] | undefined => {
134
+ if (
135
+ !isRecord(value) ||
136
+ typeof value.authorizationServerUrl !== "string" ||
137
+ !isSecureNetworkUrl(value.authorizationServerUrl)
138
+ ) {
139
+ return undefined;
140
+ }
141
+
142
+ // A malformed optional field is dropped alone: keeping the validated
143
+ // authorization server URL preserves the redirect-time binding, and the SDK
144
+ // re-discovers whatever metadata is missing.
145
+ const state: NonNullable<MCPPersistedAuthState["discoveryState"]> = {
146
+ authorizationServerUrl: value.authorizationServerUrl,
147
+ };
148
+
149
+ if (
150
+ typeof value.resourceMetadataUrl === "string" &&
151
+ isSecureNetworkUrl(value.resourceMetadataUrl)
152
+ ) {
153
+ state.resourceMetadataUrl = value.resourceMetadataUrl;
154
+ }
155
+
156
+ const metadata = OAuthMetadataSchema.safeParse(
157
+ value.authorizationServerMetadata,
158
+ );
159
+ if (metadata.success) state.authorizationServerMetadata = metadata.data;
160
+
161
+ const resourceMetadata = OAuthProtectedResourceMetadataSchema.safeParse(
162
+ value.resourceMetadata,
163
+ );
164
+ if (resourceMetadata.success) state.resourceMetadata = resourceMetadata.data;
165
+
166
+ return state;
167
+ };
168
+
169
+ export const normalizePersistedAuthState = (
170
+ value: unknown,
171
+ ): MCPPersistedAuthState | null => {
172
+ if (!isRecord(value)) return null;
173
+ if ("serverUrl" in value && !isMcpServerUrl(value.serverUrl)) return null;
174
+
175
+ const state: MCPPersistedAuthState = {};
176
+ if (isMcpServerUrl(value.serverUrl)) {
177
+ state.serverUrl = normalizeMcpServerUrl(value.serverUrl);
178
+ }
179
+ if (isNonEmptyString(value.token)) state.token = value.token;
180
+ if (isNonEmptyString(value.tokensClientId)) {
181
+ state.tokensClientId = value.tokensClientId;
182
+ }
183
+ if (isNonEmptyString(value.codeVerifier)) {
184
+ state.codeVerifier = value.codeVerifier;
185
+ }
186
+ if (isNonEmptyString(value.state)) state.state = value.state;
187
+
188
+ const tokens = normalizeOAuthTokens(value.tokens);
189
+ if (tokens) state.tokens = tokens;
190
+
191
+ const clientInformation = normalizeClientInformation(value.clientInformation);
192
+ if (clientInformation) state.clientInformation = clientInformation;
193
+ if (value.clientInformationSource === "registered") {
194
+ state.clientInformationSource = value.clientInformationSource;
195
+ }
196
+
197
+ const discoveryState = normalizeDiscoveryState(value.discoveryState);
198
+ if (discoveryState) state.discoveryState = discoveryState;
199
+
200
+ return Object.keys(state).length > 0 ? state : null;
201
+ };