@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.
- package/LICENSE +1 -1
- package/dist/auth/createOAuthProvider.d.ts.map +1 -1
- package/dist/auth/createOAuthProvider.js +7 -1
- package/dist/auth/createOAuthProvider.js.map +1 -1
- package/dist/context/McpConnectorByIndexProvider.js.map +1 -1
- package/dist/context/McpCustomServerByIndexProvider.js.map +1 -1
- package/dist/context/McpServerByIdProvider.js.map +1 -1
- package/dist/hooks/useMcpOAuthCallback.js.map +1 -1
- package/dist/primitives/addForm/McpAddFormAuthFields.js.map +1 -1
- package/dist/primitives/addForm/McpAddFormAuthSelect.js.map +1 -1
- package/dist/primitives/addForm/McpAddFormBearerTokenField.js.map +1 -1
- package/dist/primitives/addForm/McpAddFormCancel.js.map +1 -1
- package/dist/primitives/addForm/McpAddFormError.js.map +1 -1
- package/dist/primitives/addForm/McpAddFormNameField.js.map +1 -1
- package/dist/primitives/addForm/McpAddFormRoot.d.ts.map +1 -1
- package/dist/primitives/addForm/McpAddFormRoot.js +14 -5
- package/dist/primitives/addForm/McpAddFormRoot.js.map +1 -1
- package/dist/primitives/addForm/McpAddFormScopesField.js.map +1 -1
- package/dist/primitives/addForm/McpAddFormSubmit.js.map +1 -1
- package/dist/primitives/addForm/McpAddFormUrlField.js.map +1 -1
- package/dist/primitives/elicitation/McpElicitationPrimitiveAccept.js.map +1 -1
- package/dist/primitives/elicitation/McpElicitationPrimitiveCancel.js.map +1 -1
- package/dist/primitives/elicitation/McpElicitationPrimitiveDecline.js.map +1 -1
- package/dist/primitives/elicitation/McpElicitationPrimitiveError.js.map +1 -1
- package/dist/primitives/elicitation/McpElicitationPrimitiveFields.js.map +1 -1
- package/dist/primitives/elicitation/McpElicitationPrimitiveItems.js.map +1 -1
- package/dist/primitives/elicitation/McpElicitationPrimitiveMessage.js.map +1 -1
- package/dist/primitives/elicitation/McpElicitationPrimitiveRoot.js.map +1 -1
- package/dist/primitives/manager/McpManagerAddCustomTrigger.js.map +1 -1
- package/dist/primitives/manager/McpManagerRoot.js.map +1 -1
- package/dist/primitives/server/McpServerConnectButton.js.map +1 -1
- package/dist/primitives/server/McpServerDisconnectButton.js.map +1 -1
- package/dist/primitives/server/McpServerError.js.map +1 -1
- package/dist/primitives/server/McpServerIcon.js.map +1 -1
- package/dist/primitives/server/McpServerName.js.map +1 -1
- package/dist/primitives/server/McpServerOAuthLink.js.map +1 -1
- package/dist/primitives/server/McpServerRemoveButton.js.map +1 -1
- package/dist/primitives/server/McpServerRoot.js.map +1 -1
- package/dist/primitives/server/McpServerStatus.js.map +1 -1
- package/dist/primitives/server/McpServerToolName.js.map +1 -1
- package/dist/primitives/server/McpServerTools.js.map +1 -1
- package/dist/resources/McpCustomServerPersistence.d.ts +17 -0
- package/dist/resources/McpCustomServerPersistence.d.ts.map +1 -0
- package/dist/resources/McpCustomServerPersistence.js +192 -0
- package/dist/resources/McpCustomServerPersistence.js.map +1 -0
- package/dist/resources/McpElicitationLifecycle.d.ts +12 -0
- package/dist/resources/McpElicitationLifecycle.d.ts.map +1 -0
- package/dist/resources/McpElicitationLifecycle.js +144 -0
- package/dist/resources/McpElicitationLifecycle.js.map +1 -0
- package/dist/resources/McpManagerResource.d.ts.map +1 -1
- package/dist/resources/McpManagerResource.js +28 -205
- package/dist/resources/McpManagerResource.js.map +1 -1
- package/dist/resources/McpServerResource.d.ts.map +1 -1
- package/dist/resources/McpServerResource.js +24 -101
- package/dist/resources/McpServerResource.js.map +1 -1
- package/dist/resources/storage/McpLocalStorage.d.ts +1 -4
- package/dist/resources/storage/McpLocalStorage.d.ts.map +1 -1
- package/dist/resources/storage/McpLocalStorage.js +1 -100
- package/dist/resources/storage/McpLocalStorage.js.map +1 -1
- package/dist/resources/storage/McpStoredDataNormalization.d.ts +5 -0
- package/dist/resources/storage/McpStoredDataNormalization.d.ts.map +1 -0
- package/dist/resources/storage/McpStoredDataNormalization.js +95 -0
- package/dist/resources/storage/McpStoredDataNormalization.js.map +1 -0
- package/dist/utils/serverUrl.d.ts +1 -0
- package/dist/utils/serverUrl.d.ts.map +1 -1
- package/dist/utils/serverUrl.js +15 -1
- package/dist/utils/serverUrl.js.map +1 -1
- package/package.json +15 -11
- package/src/auth/createOAuthProvider.test.ts +30 -1
- package/src/auth/createOAuthProvider.ts +12 -4
- package/src/primitives/addForm/McpAddFormRoot.test.tsx +66 -0
- package/src/primitives/addForm/McpAddFormRoot.tsx +11 -1
- package/src/resources/McpCustomServerPersistence.test.ts +56 -0
- package/src/resources/McpCustomServerPersistence.ts +233 -0
- package/src/resources/McpElicitationLifecycle.test.ts +83 -0
- package/src/resources/McpElicitationLifecycle.ts +156 -0
- package/src/resources/McpManagerResource.test.ts +36 -0
- package/src/resources/McpManagerResource.ts +25 -248
- package/src/resources/McpServerResource.test.ts +123 -10
- package/src/resources/McpServerResource.ts +29 -149
- package/src/resources/storage/McpLocalStorage.ts +9 -209
- package/src/resources/storage/McpStoredDataNormalization.test.ts +24 -0
- package/src/resources/storage/McpStoredDataNormalization.ts +201 -0
- package/src/utils/serverUrl.test.ts +30 -0
- 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
|
|
100
|
-
|
|
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
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
+
};
|