@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
|
@@ -1,11 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
useState,
|
|
3
|
-
useEffect,
|
|
4
|
-
useMemo,
|
|
5
|
-
useEffectEvent,
|
|
6
|
-
useRef,
|
|
7
|
-
useCallback,
|
|
8
|
-
} from "react";
|
|
1
|
+
import { useState, useMemo } from "react";
|
|
9
2
|
import { useResource, resource, withKey } from "@assistant-ui/tap";
|
|
10
3
|
import {
|
|
11
4
|
useClientLookup,
|
|
@@ -19,9 +12,14 @@ import { createMcpId } from "../utils/createMcpId";
|
|
|
19
12
|
import { clearOAuthProviderAuthState } from "../auth/createOAuthProvider";
|
|
20
13
|
import type { Tool } from "assistant-stream";
|
|
21
14
|
import { McpServerResource } from "./McpServerResource";
|
|
15
|
+
import {
|
|
16
|
+
McpCustomServersResource,
|
|
17
|
+
holdCustomServerPersistence,
|
|
18
|
+
type CustomServerPersistenceQueues,
|
|
19
|
+
} from "./McpCustomServerPersistence";
|
|
22
20
|
import { withMcpServerRemovalFence } from "./McpServerRemovalFence";
|
|
23
21
|
import { McpLocalStorage } from "./storage/McpLocalStorage";
|
|
24
|
-
import type {
|
|
22
|
+
import type { MCPStorageElement } from "./storage/types";
|
|
25
23
|
import { assertUniqueServerIds } from "../utils/serverId";
|
|
26
24
|
import type {
|
|
27
25
|
MCPAuthConfig,
|
|
@@ -50,229 +48,6 @@ function defaultRedirectUri(): string {
|
|
|
50
48
|
// array each render (which would invalidate the serverElements memo below).
|
|
51
49
|
const NO_CONNECTORS: MCPConnector[] = [];
|
|
52
50
|
|
|
53
|
-
const reportCustomStorageFailure = (
|
|
54
|
-
operation: "load" | "save",
|
|
55
|
-
error: unknown,
|
|
56
|
-
) => {
|
|
57
|
-
console.error(
|
|
58
|
-
`[assistant-ui/react-mcp] failed to ${operation} custom servers:`,
|
|
59
|
-
error,
|
|
60
|
-
);
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
const reportBlockedCustomServerPersistence = () => {
|
|
64
|
-
console.error(
|
|
65
|
-
"[assistant-ui/react-mcp] custom server changes remain in memory because loading the persisted list failed; remount the manager to retry",
|
|
66
|
-
);
|
|
67
|
-
};
|
|
68
|
-
|
|
69
|
-
const persistCustomServers = async (
|
|
70
|
-
storage: MCPStorage,
|
|
71
|
-
records: MCPCustomServerRecord[],
|
|
72
|
-
) => {
|
|
73
|
-
try {
|
|
74
|
-
await storage.saveCustomServers(records);
|
|
75
|
-
} catch (error) {
|
|
76
|
-
reportCustomStorageFailure("save", error);
|
|
77
|
-
}
|
|
78
|
-
};
|
|
79
|
-
|
|
80
|
-
type CustomServerPersistenceQueues = Map<string, Promise<void>>;
|
|
81
|
-
|
|
82
|
-
const enqueueCustomServerTask = (
|
|
83
|
-
persistenceQueues: CustomServerPersistenceQueues,
|
|
84
|
-
scopeKey: string,
|
|
85
|
-
task: () => Promise<void>,
|
|
86
|
-
) => {
|
|
87
|
-
const previous = persistenceQueues.get(scopeKey);
|
|
88
|
-
const next = (previous ?? Promise.resolve()).then(task);
|
|
89
|
-
persistenceQueues.set(scopeKey, next);
|
|
90
|
-
void next.then(() => {
|
|
91
|
-
if (persistenceQueues.get(scopeKey) === next) {
|
|
92
|
-
persistenceQueues.delete(scopeKey);
|
|
93
|
-
}
|
|
94
|
-
});
|
|
95
|
-
};
|
|
96
|
-
|
|
97
|
-
const enqueueCustomServerPersistence = (
|
|
98
|
-
persistenceQueues: CustomServerPersistenceQueues,
|
|
99
|
-
scopeKey: string,
|
|
100
|
-
storage: MCPStorage,
|
|
101
|
-
records: MCPCustomServerRecord[],
|
|
102
|
-
) =>
|
|
103
|
-
enqueueCustomServerTask(persistenceQueues, scopeKey, () =>
|
|
104
|
-
persistCustomServers(storage, records),
|
|
105
|
-
);
|
|
106
|
-
|
|
107
|
-
const holdCustomServerPersistence = (
|
|
108
|
-
persistenceQueues: CustomServerPersistenceQueues,
|
|
109
|
-
scopeKey: string,
|
|
110
|
-
) => {
|
|
111
|
-
let release = () => {};
|
|
112
|
-
const gate = new Promise<void>((resolve) => {
|
|
113
|
-
release = resolve;
|
|
114
|
-
});
|
|
115
|
-
enqueueCustomServerTask(persistenceQueues, scopeKey, () => gate);
|
|
116
|
-
return release;
|
|
117
|
-
};
|
|
118
|
-
|
|
119
|
-
const deduplicateCustomServers = (records: MCPCustomServerRecord[]) => {
|
|
120
|
-
const seen = new Set<string>();
|
|
121
|
-
return records.filter((record) => {
|
|
122
|
-
if (seen.has(record.id)) {
|
|
123
|
-
console.error(
|
|
124
|
-
`[assistant-ui/react-mcp] ignored duplicate custom server id "${record.id}" loaded from storage`,
|
|
125
|
-
);
|
|
126
|
-
return false;
|
|
127
|
-
}
|
|
128
|
-
seen.add(record.id);
|
|
129
|
-
return true;
|
|
130
|
-
});
|
|
131
|
-
};
|
|
132
|
-
|
|
133
|
-
type McpCustomServersResourceProps = {
|
|
134
|
-
storage: MCPStorage;
|
|
135
|
-
scopeKey: string;
|
|
136
|
-
persistenceQueues: CustomServerPersistenceQueues;
|
|
137
|
-
};
|
|
138
|
-
|
|
139
|
-
const useMcpCustomServersResource = ({
|
|
140
|
-
storage,
|
|
141
|
-
scopeKey,
|
|
142
|
-
persistenceQueues,
|
|
143
|
-
}: McpCustomServersResourceProps) => {
|
|
144
|
-
const [customServers, setCustomServers] = useState<MCPCustomServerRecord[]>(
|
|
145
|
-
[],
|
|
146
|
-
);
|
|
147
|
-
const [isHydrated, setIsHydrated] = useState(false);
|
|
148
|
-
|
|
149
|
-
const customServersRef = useRef<MCPCustomServerRecord[]>([]);
|
|
150
|
-
const hydrationStateRef = useRef<"pending" | "succeeded" | "failed">(
|
|
151
|
-
"pending",
|
|
152
|
-
);
|
|
153
|
-
const hasPendingMutationRef = useRef(false);
|
|
154
|
-
const [removedBeforeHydration] = useState(() => new Set<string>());
|
|
155
|
-
const reportedBlockedPersistenceRef = useRef(false);
|
|
156
|
-
|
|
157
|
-
const hydrate = useEffectEvent(async (signal: { cancelled: boolean }) => {
|
|
158
|
-
// A revisited scope must not read behind writes still queued against it.
|
|
159
|
-
while (true) {
|
|
160
|
-
const pendingPersistence = persistenceQueues.get(scopeKey);
|
|
161
|
-
if (!pendingPersistence) break;
|
|
162
|
-
await pendingPersistence;
|
|
163
|
-
if (signal.cancelled) return;
|
|
164
|
-
if (persistenceQueues.get(scopeKey) === pendingPersistence) break;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
let records: Awaited<ReturnType<typeof storage.loadCustomServers>>;
|
|
168
|
-
try {
|
|
169
|
-
const loadedRecords = await storage.loadCustomServers();
|
|
170
|
-
records = deduplicateCustomServers(loadedRecords);
|
|
171
|
-
} catch (error) {
|
|
172
|
-
if (!signal.cancelled) {
|
|
173
|
-
reportCustomStorageFailure("load", error);
|
|
174
|
-
hydrationStateRef.current = "failed";
|
|
175
|
-
if (hasPendingMutationRef.current) {
|
|
176
|
-
reportBlockedCustomServerPersistence();
|
|
177
|
-
reportedBlockedPersistenceRef.current = true;
|
|
178
|
-
}
|
|
179
|
-
setIsHydrated(true);
|
|
180
|
-
}
|
|
181
|
-
return;
|
|
182
|
-
}
|
|
183
|
-
// Merge rather than replace so any addCustomServer calls that
|
|
184
|
-
// happened before hydration resolved aren't silently overwritten.
|
|
185
|
-
// Persisted order wins; pre-hydration locals append.
|
|
186
|
-
const hadPendingMutation = hasPendingMutationRef.current;
|
|
187
|
-
const hydratedRecords = records.filter(
|
|
188
|
-
(record) => !removedBeforeHydration.has(record.id),
|
|
189
|
-
);
|
|
190
|
-
const mergedRecords = (() => {
|
|
191
|
-
const prev = customServersRef.current;
|
|
192
|
-
if (prev.length === 0) return hydratedRecords;
|
|
193
|
-
const persistedIds = new Set(hydratedRecords.map((r) => r.id));
|
|
194
|
-
return [
|
|
195
|
-
...hydratedRecords,
|
|
196
|
-
...prev.filter((r) => !persistedIds.has(r.id)),
|
|
197
|
-
];
|
|
198
|
-
})();
|
|
199
|
-
customServersRef.current = mergedRecords;
|
|
200
|
-
hydrationStateRef.current = "succeeded";
|
|
201
|
-
hasPendingMutationRef.current = false;
|
|
202
|
-
if (hadPendingMutation) {
|
|
203
|
-
enqueueCustomServerPersistence(
|
|
204
|
-
persistenceQueues,
|
|
205
|
-
scopeKey,
|
|
206
|
-
storage,
|
|
207
|
-
mergedRecords,
|
|
208
|
-
);
|
|
209
|
-
}
|
|
210
|
-
if (signal.cancelled) return;
|
|
211
|
-
setCustomServers(mergedRecords);
|
|
212
|
-
setIsHydrated(true);
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
useEffect(() => {
|
|
216
|
-
const signal = { cancelled: false };
|
|
217
|
-
// Hydration reads persisted records asynchronously; there is no earlier
|
|
218
|
-
// point than mount at which to start it.
|
|
219
|
-
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
220
|
-
void hydrate(signal);
|
|
221
|
-
return () => {
|
|
222
|
-
signal.cancelled = true;
|
|
223
|
-
};
|
|
224
|
-
}, []);
|
|
225
|
-
|
|
226
|
-
const updateCustomServers = useCallback(
|
|
227
|
-
(
|
|
228
|
-
updater: (records: MCPCustomServerRecord[]) => MCPCustomServerRecord[],
|
|
229
|
-
) => {
|
|
230
|
-
const next = updater(customServersRef.current);
|
|
231
|
-
customServersRef.current = next;
|
|
232
|
-
setCustomServers(next);
|
|
233
|
-
|
|
234
|
-
if (hydrationStateRef.current === "succeeded") {
|
|
235
|
-
enqueueCustomServerPersistence(
|
|
236
|
-
persistenceQueues,
|
|
237
|
-
scopeKey,
|
|
238
|
-
storage,
|
|
239
|
-
next,
|
|
240
|
-
);
|
|
241
|
-
return;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
hasPendingMutationRef.current = true;
|
|
245
|
-
if (
|
|
246
|
-
hydrationStateRef.current === "failed" &&
|
|
247
|
-
!reportedBlockedPersistenceRef.current
|
|
248
|
-
) {
|
|
249
|
-
reportBlockedCustomServerPersistence();
|
|
250
|
-
reportedBlockedPersistenceRef.current = true;
|
|
251
|
-
}
|
|
252
|
-
},
|
|
253
|
-
[persistenceQueues, scopeKey, storage],
|
|
254
|
-
);
|
|
255
|
-
|
|
256
|
-
const removeCustomServer = useCallback(
|
|
257
|
-
(id: string) => {
|
|
258
|
-
if (hydrationStateRef.current === "pending") {
|
|
259
|
-
removedBeforeHydration.add(id);
|
|
260
|
-
}
|
|
261
|
-
updateCustomServers((prev) => prev.filter((record) => record.id !== id));
|
|
262
|
-
},
|
|
263
|
-
[removedBeforeHydration, updateCustomServers],
|
|
264
|
-
);
|
|
265
|
-
|
|
266
|
-
return {
|
|
267
|
-
customServers,
|
|
268
|
-
isHydrated,
|
|
269
|
-
updateCustomServers,
|
|
270
|
-
removeCustomServer,
|
|
271
|
-
};
|
|
272
|
-
};
|
|
273
|
-
|
|
274
|
-
const McpCustomServersResource = resource(useMcpCustomServersResource);
|
|
275
|
-
|
|
276
51
|
const useMcpManagerResource = (
|
|
277
52
|
props: McpManagerResourceProps,
|
|
278
53
|
): ClientOutput<"mcp"> => {
|
|
@@ -472,26 +247,28 @@ const useMcpManagerResource = (
|
|
|
472
247
|
`Cannot remove connector "${id}" — connectors are app-defined and not removable. Use a custom server id instead.`,
|
|
473
248
|
);
|
|
474
249
|
}
|
|
475
|
-
|
|
476
|
-
// clears auth state, and unregisters from customServers in one
|
|
477
|
-
// place. Fallback to manual cleanup if the lookup is empty
|
|
478
|
-
// (server already gone).
|
|
250
|
+
let server;
|
|
479
251
|
try {
|
|
480
|
-
|
|
252
|
+
server = lookup.get({ key: id });
|
|
481
253
|
} catch {
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
);
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
254
|
+
server = undefined;
|
|
255
|
+
}
|
|
256
|
+
if (server) {
|
|
257
|
+
await server.remove();
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
const releasePersistence = holdCustomServerPersistence(
|
|
261
|
+
persistenceQueues,
|
|
262
|
+
storageScopeKey,
|
|
263
|
+
);
|
|
264
|
+
try {
|
|
265
|
+
await clearOAuthProviderAuthState(storage, id);
|
|
266
|
+
removeCustomServer(id);
|
|
267
|
+
} catch (error) {
|
|
493
268
|
releasePersistence();
|
|
269
|
+
throw error;
|
|
494
270
|
}
|
|
271
|
+
releasePersistence();
|
|
495
272
|
},
|
|
496
273
|
};
|
|
497
274
|
};
|
|
@@ -157,6 +157,7 @@ const mount = (
|
|
|
157
157
|
cache?: { readonly defaultTtlMs?: number } | undefined;
|
|
158
158
|
elicitation?: boolean | undefined;
|
|
159
159
|
kind?: "connector" | "custom" | undefined;
|
|
160
|
+
url?: string | undefined;
|
|
160
161
|
onRemove?: (() => Promise<void>) | undefined;
|
|
161
162
|
},
|
|
162
163
|
onMount?: (server: ClientOutput<"mcpServer">) => void,
|
|
@@ -170,7 +171,7 @@ const mount = (
|
|
|
170
171
|
id: "docs",
|
|
171
172
|
kind: props?.kind ?? "connector",
|
|
172
173
|
name: "Docs",
|
|
173
|
-
url: "https://example.com/mcp",
|
|
174
|
+
url: props?.url ?? "https://example.com/mcp",
|
|
174
175
|
auth: props?.auth ?? { type: "none" },
|
|
175
176
|
storage: props?.storage ?? createStorage(),
|
|
176
177
|
redirectUri: "https://example.com/callback",
|
|
@@ -205,6 +206,75 @@ const getOAuthProvider = (index: number) => {
|
|
|
205
206
|
describe("McpServerResource automatic authentication", () => {
|
|
206
207
|
beforeEach(resetMocks);
|
|
207
208
|
|
|
209
|
+
it.each(["bearer", "oauth"] as const)(
|
|
210
|
+
"rejects %s authentication over remote HTTP",
|
|
211
|
+
async (type) => {
|
|
212
|
+
const root = mount({
|
|
213
|
+
auth: type === "bearer" ? { type, token: "secret" } : { type },
|
|
214
|
+
url: "http://mcp.example.com/mcp",
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
await root.getValue().connect();
|
|
219
|
+
await waitForResourceUpdate(
|
|
220
|
+
() => root.getValue().getState().connectionState === "error",
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
expect(root.getValue().getState()).toMatchObject({
|
|
224
|
+
connectionState: "error",
|
|
225
|
+
lastError: {
|
|
226
|
+
message:
|
|
227
|
+
'Authenticated MCP server "docs" must use HTTPS or loopback HTTP.',
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
expect(mocks.StreamableHTTPClientTransport).not.toHaveBeenCalled();
|
|
231
|
+
} finally {
|
|
232
|
+
root.unmount();
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
it.each([
|
|
238
|
+
"http://localhost:3000/mcp",
|
|
239
|
+
"http://dev.localhost:3000/mcp",
|
|
240
|
+
"http://127.0.0.1:3000/mcp",
|
|
241
|
+
"http://127.0.0.2:3000/mcp",
|
|
242
|
+
"http://[::1]:3000/mcp",
|
|
243
|
+
"http://localhost.:3000/mcp",
|
|
244
|
+
"http://dev.localhost.:3000/mcp",
|
|
245
|
+
"http://[::ffff:127.0.0.1]:3000/mcp",
|
|
246
|
+
])("allows bearer authentication over loopback HTTP at %s", async (url) => {
|
|
247
|
+
const root = mount({
|
|
248
|
+
auth: { type: "bearer", token: "secret" },
|
|
249
|
+
url,
|
|
250
|
+
autoConnect: true,
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
try {
|
|
254
|
+
await waitFor(() => mocks.transports.length > 0);
|
|
255
|
+
expect(mocks.StreamableHTTPClientTransport).toHaveBeenCalledWith(
|
|
256
|
+
new URL(url),
|
|
257
|
+
{ requestInit: { headers: { Authorization: "Bearer secret" } } },
|
|
258
|
+
);
|
|
259
|
+
} finally {
|
|
260
|
+
root.unmount();
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("allows unauthenticated MCP servers over remote HTTP", async () => {
|
|
265
|
+
const url = "http://mcp.example.com/mcp";
|
|
266
|
+
const root = mount({ auth: { type: "none" }, url, autoConnect: true });
|
|
267
|
+
|
|
268
|
+
try {
|
|
269
|
+
await waitFor(() => mocks.transports.length > 0);
|
|
270
|
+
expect(mocks.StreamableHTTPClientTransport).toHaveBeenCalledWith(
|
|
271
|
+
new URL(url),
|
|
272
|
+
);
|
|
273
|
+
} finally {
|
|
274
|
+
root.unmount();
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
|
|
208
278
|
it("does not auto-connect with authentication from another server URL", async () => {
|
|
209
279
|
const storage = createStorage();
|
|
210
280
|
vi.mocked(storage.loadAuthState).mockResolvedValue({
|
|
@@ -642,26 +712,69 @@ describe("McpServerResource connectionTimeout", () => {
|
|
|
642
712
|
describe("McpServerResource connection lifecycle", () => {
|
|
643
713
|
beforeEach(resetMocks);
|
|
644
714
|
|
|
645
|
-
it(
|
|
715
|
+
it.each([
|
|
716
|
+
"https://auth.example.com/current",
|
|
717
|
+
"http://localhost:3000/current",
|
|
718
|
+
])(
|
|
719
|
+
"publishes authorization URLs from the current connection at %s",
|
|
720
|
+
async (url) => {
|
|
721
|
+
const root = mount({ auth: { type: "oauth" } });
|
|
722
|
+
|
|
723
|
+
try {
|
|
724
|
+
await root.getValue().connect();
|
|
725
|
+
await getOAuthProvider(0).redirectToAuthorization(new URL(url));
|
|
726
|
+
await waitForResourceUpdate(
|
|
727
|
+
() => root.getValue().getState().authorizationUrl !== null,
|
|
728
|
+
);
|
|
729
|
+
|
|
730
|
+
expect(root.getValue().getState().authorizationUrl).toBe(url);
|
|
731
|
+
} finally {
|
|
732
|
+
root.unmount();
|
|
733
|
+
}
|
|
734
|
+
},
|
|
735
|
+
);
|
|
736
|
+
|
|
737
|
+
it("rejects remote HTTP OAuth authorization URLs from the current connection", async () => {
|
|
646
738
|
const root = mount({ auth: { type: "oauth" } });
|
|
647
739
|
|
|
648
740
|
try {
|
|
649
741
|
await root.getValue().connect();
|
|
650
|
-
await getOAuthProvider(0).redirectToAuthorization(
|
|
651
|
-
new URL("https://auth.example.com/current"),
|
|
652
|
-
);
|
|
653
|
-
await waitForResourceUpdate(
|
|
654
|
-
() => root.getValue().getState().authorizationUrl !== null,
|
|
655
|
-
);
|
|
656
742
|
|
|
657
|
-
expect(
|
|
658
|
-
|
|
743
|
+
await expect(
|
|
744
|
+
getOAuthProvider(0).redirectToAuthorization(
|
|
745
|
+
new URL("http://auth.example.com/authorize"),
|
|
746
|
+
),
|
|
747
|
+
).rejects.toThrow(
|
|
748
|
+
"MCP OAuth authorization URL must use HTTPS or loopback HTTP.",
|
|
659
749
|
);
|
|
750
|
+
expect(root.getValue().getState().authorizationUrl).toBeNull();
|
|
660
751
|
} finally {
|
|
661
752
|
root.unmount();
|
|
662
753
|
}
|
|
663
754
|
});
|
|
664
755
|
|
|
756
|
+
it.each(["javascript:", "data:", "file:"])(
|
|
757
|
+
"rejects %s OAuth authorization URLs from the current connection",
|
|
758
|
+
async (protocol) => {
|
|
759
|
+
const root = mount({ auth: { type: "oauth" } });
|
|
760
|
+
|
|
761
|
+
try {
|
|
762
|
+
await root.getValue().connect();
|
|
763
|
+
|
|
764
|
+
await expect(
|
|
765
|
+
getOAuthProvider(0).redirectToAuthorization(
|
|
766
|
+
new URL(`${protocol}unsafe`),
|
|
767
|
+
),
|
|
768
|
+
).rejects.toThrow(
|
|
769
|
+
`Unsupported MCP OAuth authorization URL protocol: ${protocol}`,
|
|
770
|
+
);
|
|
771
|
+
expect(root.getValue().getState().authorizationUrl).toBeNull();
|
|
772
|
+
} finally {
|
|
773
|
+
root.unmount();
|
|
774
|
+
}
|
|
775
|
+
},
|
|
776
|
+
);
|
|
777
|
+
|
|
665
778
|
it("ignores authorization URLs after disconnect", async () => {
|
|
666
779
|
const root = mount({ auth: { type: "oauth" } });
|
|
667
780
|
|