@assistant-ui/react-mcp 0.1.18 → 0.1.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/buildHeaders.d.ts +3 -6
- package/dist/auth/buildHeaders.d.ts.map +1 -1
- package/dist/auth/createOAuthProvider.d.ts +24 -26
- package/dist/auth/createOAuthProvider.d.ts.map +1 -1
- package/dist/auth/types.d.ts +13 -16
- package/dist/auth/types.d.ts.map +1 -1
- package/dist/connector.d.ts +2 -5
- package/dist/connector.d.ts.map +1 -1
- package/dist/context/McpConnectorByIndexProvider.d.ts +3 -6
- package/dist/context/McpConnectorByIndexProvider.d.ts.map +1 -1
- package/dist/context/McpConnectorByIndexProvider.js +1 -1
- package/dist/context/McpCustomServerByIndexProvider.d.ts +3 -6
- package/dist/context/McpCustomServerByIndexProvider.d.ts.map +1 -1
- package/dist/context/McpCustomServerByIndexProvider.js +1 -1
- package/dist/context/McpServerByIdProvider.d.ts +3 -6
- package/dist/context/McpServerByIdProvider.d.ts.map +1 -1
- package/dist/context/McpServerByIdProvider.js +1 -1
- package/dist/hooks/useMcpOAuthCallback.d.ts +14 -17
- package/dist/hooks/useMcpOAuthCallback.d.ts.map +1 -1
- package/dist/hooks/useMcpOAuthCallback.js +6 -2
- package/dist/hooks/useMcpOAuthCallback.js.map +1 -1
- package/dist/index.d.ts +19 -18
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -4
- package/dist/mcp-scope.d.ts +124 -127
- package/dist/mcp-scope.d.ts.map +1 -1
- package/dist/primitives/addForm/McpAddFormAuthFields.d.ts +14 -16
- package/dist/primitives/addForm/McpAddFormAuthFields.d.ts.map +1 -1
- package/dist/primitives/addForm/McpAddFormAuthFields.js +18 -71
- package/dist/primitives/addForm/McpAddFormAuthFields.js.map +1 -1
- package/dist/primitives/addForm/McpAddFormAuthSelect.d.ts +5 -8
- package/dist/primitives/addForm/McpAddFormAuthSelect.d.ts.map +1 -1
- package/dist/primitives/addForm/McpAddFormAuthSelect.js +1 -1
- package/dist/primitives/addForm/McpAddFormBearerTokenField.d.ts +8 -0
- package/dist/primitives/addForm/McpAddFormBearerTokenField.d.ts.map +1 -0
- package/dist/primitives/addForm/McpAddFormBearerTokenField.js +50 -0
- package/dist/primitives/addForm/McpAddFormBearerTokenField.js.map +1 -0
- package/dist/primitives/addForm/McpAddFormCancel.d.ts +6 -9
- package/dist/primitives/addForm/McpAddFormCancel.d.ts.map +1 -1
- package/dist/primitives/addForm/McpAddFormCancel.js +1 -1
- package/dist/primitives/addForm/McpAddFormError.d.ts +6 -9
- package/dist/primitives/addForm/McpAddFormError.d.ts.map +1 -1
- package/dist/primitives/addForm/McpAddFormError.js +1 -1
- package/dist/primitives/addForm/McpAddFormNameField.d.ts +5 -8
- package/dist/primitives/addForm/McpAddFormNameField.d.ts.map +1 -1
- package/dist/primitives/addForm/McpAddFormNameField.js +1 -1
- package/dist/primitives/addForm/McpAddFormRoot.d.ts +11 -14
- package/dist/primitives/addForm/McpAddFormRoot.d.ts.map +1 -1
- package/dist/primitives/addForm/McpAddFormRoot.js +2 -2
- package/dist/primitives/addForm/McpAddFormScopesField.d.ts +8 -0
- package/dist/primitives/addForm/McpAddFormScopesField.d.ts.map +1 -0
- package/dist/primitives/addForm/McpAddFormScopesField.js +38 -0
- package/dist/primitives/addForm/McpAddFormScopesField.js.map +1 -0
- package/dist/primitives/addForm/McpAddFormSubmit.d.ts +6 -9
- package/dist/primitives/addForm/McpAddFormSubmit.d.ts.map +1 -1
- package/dist/primitives/addForm/McpAddFormSubmit.js +1 -1
- package/dist/primitives/addForm/McpAddFormUrlField.d.ts +5 -8
- package/dist/primitives/addForm/McpAddFormUrlField.d.ts.map +1 -1
- package/dist/primitives/addForm/McpAddFormUrlField.js +1 -1
- package/dist/primitives/addForm/context.d.ts +25 -28
- package/dist/primitives/addForm/context.d.ts.map +1 -1
- package/dist/primitives/addForm.d.ts +10 -13
- package/dist/primitives/addForm.d.ts.map +1 -1
- package/dist/primitives/addForm.js +5 -1
- package/dist/primitives/addForm.js.map +1 -1
- package/dist/primitives/elicitation/McpElicitationPrimitiveAccept.d.ts +6 -9
- package/dist/primitives/elicitation/McpElicitationPrimitiveAccept.d.ts.map +1 -1
- package/dist/primitives/elicitation/McpElicitationPrimitiveAccept.js +2 -2
- package/dist/primitives/elicitation/McpElicitationPrimitiveCancel.d.ts +6 -9
- package/dist/primitives/elicitation/McpElicitationPrimitiveCancel.d.ts.map +1 -1
- package/dist/primitives/elicitation/McpElicitationPrimitiveCancel.js +2 -2
- package/dist/primitives/elicitation/McpElicitationPrimitiveDecline.d.ts +6 -9
- package/dist/primitives/elicitation/McpElicitationPrimitiveDecline.d.ts.map +1 -1
- package/dist/primitives/elicitation/McpElicitationPrimitiveDecline.js +2 -2
- package/dist/primitives/elicitation/McpElicitationPrimitiveError.d.ts +6 -9
- package/dist/primitives/elicitation/McpElicitationPrimitiveError.d.ts.map +1 -1
- package/dist/primitives/elicitation/McpElicitationPrimitiveError.js +1 -1
- package/dist/primitives/elicitation/McpElicitationPrimitiveFields.d.ts +8 -10
- package/dist/primitives/elicitation/McpElicitationPrimitiveFields.d.ts.map +1 -1
- package/dist/primitives/elicitation/McpElicitationPrimitiveFields.js +1 -1
- package/dist/primitives/elicitation/McpElicitationPrimitiveItems.d.ts +8 -11
- package/dist/primitives/elicitation/McpElicitationPrimitiveItems.d.ts.map +1 -1
- package/dist/primitives/elicitation/McpElicitationPrimitiveItems.js +2 -2
- package/dist/primitives/elicitation/McpElicitationPrimitiveMessage.d.ts +6 -9
- package/dist/primitives/elicitation/McpElicitationPrimitiveMessage.d.ts.map +1 -1
- package/dist/primitives/elicitation/McpElicitationPrimitiveMessage.js +1 -1
- package/dist/primitives/elicitation/McpElicitationPrimitiveRoot.d.ts +6 -9
- package/dist/primitives/elicitation/McpElicitationPrimitiveRoot.d.ts.map +1 -1
- package/dist/primitives/elicitation/McpElicitationPrimitiveRoot.js +1 -1
- package/dist/primitives/elicitation/context.d.ts +15 -18
- package/dist/primitives/elicitation/context.d.ts.map +1 -1
- package/dist/primitives/elicitation/initialElicitationDraft.d.ts +1 -4
- package/dist/primitives/elicitation/initialElicitationDraft.d.ts.map +1 -1
- package/dist/primitives/elicitation/prepareElicitationContent.d.ts +4 -7
- package/dist/primitives/elicitation/prepareElicitationContent.d.ts.map +1 -1
- package/dist/primitives/elicitation/prepareElicitationContent.js +5 -1
- package/dist/primitives/elicitation/prepareElicitationContent.js.map +1 -1
- package/dist/primitives/elicitation.d.ts +8 -14
- package/dist/primitives/elicitation.d.ts.map +1 -1
- package/dist/primitives/manager/McpManagerAddCustomTrigger.d.ts +6 -9
- package/dist/primitives/manager/McpManagerAddCustomTrigger.d.ts.map +1 -1
- package/dist/primitives/manager/McpManagerAddCustomTrigger.js +1 -1
- package/dist/primitives/manager/McpManagerConnectors.d.ts +14 -17
- package/dist/primitives/manager/McpManagerConnectors.d.ts.map +1 -1
- package/dist/primitives/manager/McpManagerConnectors.js +1 -1
- package/dist/primitives/manager/McpManagerCustomServers.d.ts +14 -17
- package/dist/primitives/manager/McpManagerCustomServers.d.ts.map +1 -1
- package/dist/primitives/manager/McpManagerCustomServers.js +1 -1
- package/dist/primitives/manager/McpManagerRoot.d.ts +6 -9
- package/dist/primitives/manager/McpManagerRoot.d.ts.map +1 -1
- package/dist/primitives/manager/McpManagerRoot.js +2 -2
- package/dist/primitives/manager.d.ts +4 -9
- package/dist/primitives/manager.d.ts.map +1 -1
- package/dist/primitives/server/McpServerConnectButton.d.ts +6 -9
- package/dist/primitives/server/McpServerConnectButton.d.ts.map +1 -1
- package/dist/primitives/server/McpServerConnectButton.js +2 -2
- package/dist/primitives/server/McpServerDisconnectButton.d.ts +6 -9
- package/dist/primitives/server/McpServerDisconnectButton.d.ts.map +1 -1
- package/dist/primitives/server/McpServerDisconnectButton.js +2 -2
- package/dist/primitives/server/McpServerError.d.ts +6 -9
- package/dist/primitives/server/McpServerError.d.ts.map +1 -1
- package/dist/primitives/server/McpServerError.js +2 -2
- package/dist/primitives/server/McpServerIcon.d.ts +12 -15
- package/dist/primitives/server/McpServerIcon.d.ts.map +1 -1
- package/dist/primitives/server/McpServerIcon.js +2 -2
- package/dist/primitives/server/McpServerName.d.ts +6 -9
- package/dist/primitives/server/McpServerName.d.ts.map +1 -1
- package/dist/primitives/server/McpServerName.js +2 -2
- package/dist/primitives/server/McpServerOAuthLink.d.ts +11 -14
- package/dist/primitives/server/McpServerOAuthLink.d.ts.map +1 -1
- package/dist/primitives/server/McpServerOAuthLink.js +2 -2
- package/dist/primitives/server/McpServerRemoveButton.d.ts +6 -9
- package/dist/primitives/server/McpServerRemoveButton.d.ts.map +1 -1
- package/dist/primitives/server/McpServerRemoveButton.js +2 -2
- package/dist/primitives/server/McpServerRoot.d.ts +6 -9
- package/dist/primitives/server/McpServerRoot.d.ts.map +1 -1
- package/dist/primitives/server/McpServerRoot.js +2 -2
- package/dist/primitives/server/McpServerStatus.d.ts +6 -9
- package/dist/primitives/server/McpServerStatus.d.ts.map +1 -1
- package/dist/primitives/server/McpServerStatus.js +2 -2
- package/dist/primitives/server/McpServerToolName.d.ts +6 -9
- package/dist/primitives/server/McpServerToolName.d.ts.map +1 -1
- package/dist/primitives/server/McpServerToolName.js +1 -1
- package/dist/primitives/server/McpServerTools.d.ts +8 -11
- package/dist/primitives/server/McpServerTools.d.ts.map +1 -1
- package/dist/primitives/server/McpServerTools.js +2 -2
- package/dist/primitives/server.d.ts +11 -16
- package/dist/primitives/server.d.ts.map +1 -1
- package/dist/resources/McpManagerResource.d.ts +13 -16
- package/dist/resources/McpManagerResource.d.ts.map +1 -1
- package/dist/resources/McpManagerResource.js +95 -64
- package/dist/resources/McpManagerResource.js.map +1 -1
- package/dist/resources/McpServerRemovalFence.d.ts +3 -6
- package/dist/resources/McpServerRemovalFence.d.ts.map +1 -1
- package/dist/resources/McpServerResource.d.ts +21 -24
- package/dist/resources/McpServerResource.d.ts.map +1 -1
- package/dist/resources/McpServerResource.js +55 -47
- package/dist/resources/McpServerResource.js.map +1 -1
- package/dist/resources/storage/McpCustomStorage.d.ts +2 -5
- package/dist/resources/storage/McpCustomStorage.d.ts.map +1 -1
- package/dist/resources/storage/McpLocalStorage.d.ts +18 -21
- package/dist/resources/storage/McpLocalStorage.d.ts.map +1 -1
- package/dist/resources/storage/McpLocalStorage.js +1 -1
- package/dist/resources/storage/McpMemoryStorage.d.ts +2 -5
- package/dist/resources/storage/McpMemoryStorage.d.ts.map +1 -1
- package/dist/resources/storage/types.d.ts +24 -27
- package/dist/resources/storage/types.d.ts.map +1 -1
- package/dist/resources/validateElicitationContent.d.ts +4 -6
- package/dist/resources/validateElicitationContent.d.ts.map +1 -1
- package/dist/utils/createMcpId.d.ts +1 -4
- package/dist/utils/createMcpId.d.ts.map +1 -1
- package/dist/utils/invokeMcpCallback.d.ts +1 -4
- package/dist/utils/invokeMcpCallback.d.ts.map +1 -1
- package/dist/utils/serverId.d.ts +2 -5
- package/dist/utils/serverId.d.ts.map +1 -1
- package/dist/utils/serverUrl.d.ts +4 -7
- package/dist/utils/serverUrl.d.ts.map +1 -1
- package/package.json +11 -10
- package/src/hooks/useMcpOAuthCallback.test.ts +53 -0
- package/src/hooks/useMcpOAuthCallback.tsx +12 -0
- package/src/index.ts +2 -1
- package/src/primitives/addForm/McpAddFormAccessibility.test.tsx +49 -0
- package/src/primitives/addForm/McpAddFormAuthFields.tsx +7 -15
- package/src/primitives/addForm/McpAddFormBearerTokenField.tsx +46 -0
- package/src/primitives/addForm/McpAddFormRoot.test.tsx +65 -0
- package/src/primitives/addForm/McpAddFormScopesField.tsx +34 -0
- package/src/primitives/addForm.ts +2 -0
- package/src/primitives/elicitation/prepareElicitationContent.ts +8 -6
- package/src/resources/McpManagerResource.test.ts +85 -0
- package/src/resources/McpManagerResource.ts +56 -23
- package/src/resources/McpServerResource.test.ts +121 -6
- package/src/resources/McpServerResource.ts +28 -12
|
@@ -92,6 +92,51 @@ describe("McpManagerResource server ids", () => {
|
|
|
92
92
|
expect(() => assertUniqueServerIds(["docs", "linear"])).not.toThrow();
|
|
93
93
|
});
|
|
94
94
|
|
|
95
|
+
it("keeps the first persisted custom server when ids are duplicated", async () => {
|
|
96
|
+
const docsServer: MCPCustomServerRecord = {
|
|
97
|
+
id: "docs",
|
|
98
|
+
name: "Docs",
|
|
99
|
+
url: "https://example.com/docs/mcp",
|
|
100
|
+
auth: { type: "none" },
|
|
101
|
+
createdAt: 1,
|
|
102
|
+
};
|
|
103
|
+
const saveCustomServers = vi.fn(async () => {});
|
|
104
|
+
const consoleError = vi
|
|
105
|
+
.spyOn(console, "error")
|
|
106
|
+
.mockImplementation(() => {});
|
|
107
|
+
const root = mount(
|
|
108
|
+
[],
|
|
109
|
+
McpCustomStorage({
|
|
110
|
+
loadCustomServers: vi.fn(async () => [
|
|
111
|
+
docsServer,
|
|
112
|
+
{ ...docsServer, name: "Duplicate Docs", createdAt: 2 },
|
|
113
|
+
]),
|
|
114
|
+
saveCustomServers,
|
|
115
|
+
loadAuthState: vi.fn(async () => null),
|
|
116
|
+
saveAuthState: vi.fn(async () => {}),
|
|
117
|
+
clearAuthState: vi.fn(async () => {}),
|
|
118
|
+
}),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
await vi.waitFor(() =>
|
|
123
|
+
expect(root.getValue().getState().isHydrated).toBe(true),
|
|
124
|
+
);
|
|
125
|
+
expect(root.getValue().getState().customServers).toHaveLength(1);
|
|
126
|
+
expect(root.getValue().getState().customServers[0]).toMatchObject({
|
|
127
|
+
id: "docs",
|
|
128
|
+
name: "Docs",
|
|
129
|
+
});
|
|
130
|
+
expect(saveCustomServers).not.toHaveBeenCalled();
|
|
131
|
+
expect(consoleError).toHaveBeenCalledWith(
|
|
132
|
+
'[assistant-ui/react-mcp] ignored duplicate custom server id "docs" loaded from storage',
|
|
133
|
+
);
|
|
134
|
+
} finally {
|
|
135
|
+
consoleError.mockRestore();
|
|
136
|
+
root.unmount();
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
95
140
|
it("passes connector cache configuration to its client", async () => {
|
|
96
141
|
mocks.Client.mockClear();
|
|
97
142
|
const root = mount([
|
|
@@ -374,6 +419,46 @@ describe("McpManagerResource storage failures", () => {
|
|
|
374
419
|
});
|
|
375
420
|
|
|
376
421
|
describe("McpManagerResource storage ordering", () => {
|
|
422
|
+
it("preserves a removal made before custom server hydration finishes", async () => {
|
|
423
|
+
const docsServer: MCPCustomServerRecord = {
|
|
424
|
+
id: "docs",
|
|
425
|
+
name: "Docs",
|
|
426
|
+
url: "https://example.com/docs/mcp",
|
|
427
|
+
auth: { type: "none" },
|
|
428
|
+
createdAt: 1,
|
|
429
|
+
};
|
|
430
|
+
let resolveLoad!: (records: MCPCustomServerRecord[]) => void;
|
|
431
|
+
const load = new Promise<MCPCustomServerRecord[]>((resolve) => {
|
|
432
|
+
resolveLoad = resolve;
|
|
433
|
+
});
|
|
434
|
+
const saveCustomServers = vi.fn(async () => {});
|
|
435
|
+
const root = mount(
|
|
436
|
+
[],
|
|
437
|
+
McpCustomStorage({
|
|
438
|
+
loadCustomServers: vi.fn(() => load),
|
|
439
|
+
saveCustomServers,
|
|
440
|
+
loadAuthState: vi.fn(async () => null),
|
|
441
|
+
saveAuthState: vi.fn(async () => {}),
|
|
442
|
+
clearAuthState: vi.fn(async () => {}),
|
|
443
|
+
}),
|
|
444
|
+
);
|
|
445
|
+
|
|
446
|
+
try {
|
|
447
|
+
await root.getValue().removeServer("docs");
|
|
448
|
+
resolveLoad([docsServer]);
|
|
449
|
+
|
|
450
|
+
await vi.waitFor(() =>
|
|
451
|
+
expect(root.getValue().getState().isHydrated).toBe(true),
|
|
452
|
+
);
|
|
453
|
+
expect(root.getValue().getState().customServers).toHaveLength(0);
|
|
454
|
+
await vi.waitFor(() =>
|
|
455
|
+
expect(saveCustomServers).toHaveBeenCalledWith([]),
|
|
456
|
+
);
|
|
457
|
+
} finally {
|
|
458
|
+
root.unmount();
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
|
|
377
462
|
it("persists custom server updates in invocation order", async () => {
|
|
378
463
|
let resolveFirstSave: (() => void) | undefined;
|
|
379
464
|
const firstSave = new Promise<void>((resolve) => {
|
|
@@ -116,6 +116,20 @@ const holdCustomServerPersistence = (
|
|
|
116
116
|
return release;
|
|
117
117
|
};
|
|
118
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
|
+
|
|
119
133
|
type McpCustomServersResourceProps = {
|
|
120
134
|
storage: MCPStorage;
|
|
121
135
|
scopeKey: string;
|
|
@@ -137,6 +151,7 @@ const useMcpCustomServersResource = ({
|
|
|
137
151
|
"pending",
|
|
138
152
|
);
|
|
139
153
|
const hasPendingMutationRef = useRef(false);
|
|
154
|
+
const [removedBeforeHydration] = useState(() => new Set<string>());
|
|
140
155
|
const reportedBlockedPersistenceRef = useRef(false);
|
|
141
156
|
|
|
142
157
|
const hydrate = useEffectEvent(async (signal: { cancelled: boolean }) => {
|
|
@@ -151,7 +166,8 @@ const useMcpCustomServersResource = ({
|
|
|
151
166
|
|
|
152
167
|
let records: Awaited<ReturnType<typeof storage.loadCustomServers>>;
|
|
153
168
|
try {
|
|
154
|
-
|
|
169
|
+
const loadedRecords = await storage.loadCustomServers();
|
|
170
|
+
records = deduplicateCustomServers(loadedRecords);
|
|
155
171
|
} catch (error) {
|
|
156
172
|
if (!signal.cancelled) {
|
|
157
173
|
reportCustomStorageFailure("load", error);
|
|
@@ -164,16 +180,21 @@ const useMcpCustomServersResource = ({
|
|
|
164
180
|
}
|
|
165
181
|
return;
|
|
166
182
|
}
|
|
167
|
-
|
|
168
183
|
// Merge rather than replace so any addCustomServer calls that
|
|
169
184
|
// happened before hydration resolved aren't silently overwritten.
|
|
170
185
|
// Persisted order wins; pre-hydration locals append.
|
|
171
186
|
const hadPendingMutation = hasPendingMutationRef.current;
|
|
187
|
+
const hydratedRecords = records.filter(
|
|
188
|
+
(record) => !removedBeforeHydration.has(record.id),
|
|
189
|
+
);
|
|
172
190
|
const mergedRecords = (() => {
|
|
173
191
|
const prev = customServersRef.current;
|
|
174
|
-
if (prev.length === 0) return
|
|
175
|
-
const persistedIds = new Set(
|
|
176
|
-
return [
|
|
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
|
+
];
|
|
177
198
|
})();
|
|
178
199
|
customServersRef.current = mergedRecords;
|
|
179
200
|
hydrationStateRef.current = "succeeded";
|
|
@@ -232,7 +253,22 @@ const useMcpCustomServersResource = ({
|
|
|
232
253
|
[persistenceQueues, scopeKey, storage],
|
|
233
254
|
);
|
|
234
255
|
|
|
235
|
-
|
|
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
|
+
};
|
|
236
272
|
};
|
|
237
273
|
|
|
238
274
|
const McpCustomServersResource = resource(useMcpCustomServersResource);
|
|
@@ -252,16 +288,17 @@ const useMcpManagerResource = (
|
|
|
252
288
|
);
|
|
253
289
|
const storageScopeKey =
|
|
254
290
|
storage.scopeId === undefined ? "unscoped" : `scoped:${storage.scopeId}`;
|
|
255
|
-
const { customServers, isHydrated, updateCustomServers } =
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
291
|
+
const { customServers, isHydrated, updateCustomServers, removeCustomServer } =
|
|
292
|
+
useResource(
|
|
293
|
+
withKey(
|
|
294
|
+
storageScopeKey,
|
|
295
|
+
McpCustomServersResource({
|
|
296
|
+
storage,
|
|
297
|
+
scopeKey: storageScopeKey,
|
|
298
|
+
persistenceQueues,
|
|
299
|
+
}),
|
|
300
|
+
),
|
|
301
|
+
);
|
|
265
302
|
|
|
266
303
|
const serverElements = useMemo(() => {
|
|
267
304
|
assertUniqueServerIds([
|
|
@@ -313,9 +350,7 @@ const useMcpManagerResource = (
|
|
|
313
350
|
? { elicitation: s.elicitation }
|
|
314
351
|
: {}),
|
|
315
352
|
onRemove: async () => {
|
|
316
|
-
|
|
317
|
-
prev.filter((record) => record.id !== s.id),
|
|
318
|
-
);
|
|
353
|
+
removeCustomServer(s.id);
|
|
319
354
|
},
|
|
320
355
|
},
|
|
321
356
|
() =>
|
|
@@ -332,7 +367,7 @@ const useMcpManagerResource = (
|
|
|
332
367
|
redirectUri,
|
|
333
368
|
autoConnect,
|
|
334
369
|
connectionTimeout,
|
|
335
|
-
|
|
370
|
+
removeCustomServer,
|
|
336
371
|
persistenceQueues,
|
|
337
372
|
storageScopeKey,
|
|
338
373
|
]);
|
|
@@ -450,9 +485,7 @@ const useMcpManagerResource = (
|
|
|
450
485
|
);
|
|
451
486
|
try {
|
|
452
487
|
await clearOAuthProviderAuthState(storage, id);
|
|
453
|
-
|
|
454
|
-
prev.filter((record) => record.id !== id),
|
|
455
|
-
);
|
|
488
|
+
removeCustomServer(id);
|
|
456
489
|
} catch (error) {
|
|
457
490
|
releasePersistence();
|
|
458
491
|
throw error;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createTapRoot, resource, useResource } from "@assistant-ui/tap";
|
|
2
2
|
import type { ClientOutput } from "@assistant-ui/store";
|
|
3
|
+
import { UnauthorizedError } from "@modelcontextprotocol/client";
|
|
3
4
|
import { useEffect, useState } from "react";
|
|
4
5
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
6
|
import type { MCPAuthConfig } from "../mcp-scope";
|
|
@@ -113,8 +114,8 @@ const requestElicitation = (
|
|
|
113
114
|
client: any,
|
|
114
115
|
message: string,
|
|
115
116
|
requestedSchema: unknown,
|
|
116
|
-
context: { signal: AbortSignal } = {
|
|
117
|
-
signal: new AbortController().signal,
|
|
117
|
+
context: { mcpReq: { signal: AbortSignal } } = {
|
|
118
|
+
mcpReq: { signal: new AbortController().signal },
|
|
118
119
|
},
|
|
119
120
|
) => {
|
|
120
121
|
const handler = client.requestHandlers.get("elicitation/create");
|
|
@@ -156,7 +157,6 @@ const mount = (
|
|
|
156
157
|
cache?: { readonly defaultTtlMs?: number } | undefined;
|
|
157
158
|
elicitation?: boolean | undefined;
|
|
158
159
|
kind?: "connector" | "custom" | undefined;
|
|
159
|
-
storage?: MCPStorage | undefined;
|
|
160
160
|
onRemove?: (() => Promise<void>) | undefined;
|
|
161
161
|
},
|
|
162
162
|
onMount?: (server: ClientOutput<"mcpServer">) => void,
|
|
@@ -193,6 +193,15 @@ const mount = (
|
|
|
193
193
|
const unboundAuthMessage =
|
|
194
194
|
'MCP server "docs" has saved authentication for a different URL. Authenticate again to connect to https://example.com/mcp.';
|
|
195
195
|
|
|
196
|
+
const getOAuthProvider = (index: number) => {
|
|
197
|
+
const provider =
|
|
198
|
+
mocks.StreamableHTTPClientTransport.mock.calls[index]?.[1]?.authProvider;
|
|
199
|
+
if (!provider) throw new Error("Expected OAuth provider");
|
|
200
|
+
return provider as {
|
|
201
|
+
redirectToAuthorization: (url: URL) => Promise<void>;
|
|
202
|
+
};
|
|
203
|
+
};
|
|
204
|
+
|
|
196
205
|
describe("McpServerResource automatic authentication", () => {
|
|
197
206
|
beforeEach(resetMocks);
|
|
198
207
|
|
|
@@ -292,7 +301,9 @@ describe("McpServerResource automatic authentication", () => {
|
|
|
292
301
|
});
|
|
293
302
|
|
|
294
303
|
try {
|
|
295
|
-
await waitFor(
|
|
304
|
+
await waitFor(
|
|
305
|
+
() => vi.mocked(storage.loadAuthState).mock.calls.length > 0,
|
|
306
|
+
);
|
|
296
307
|
await flushMacrotask();
|
|
297
308
|
|
|
298
309
|
expect(root.getValue().getState()).toMatchObject({
|
|
@@ -338,7 +349,9 @@ describe("McpServerResource automatic authentication", () => {
|
|
|
338
349
|
});
|
|
339
350
|
|
|
340
351
|
try {
|
|
341
|
-
await waitFor(
|
|
352
|
+
await waitFor(
|
|
353
|
+
() => vi.mocked(storage.loadAuthState).mock.calls.length > 0,
|
|
354
|
+
);
|
|
342
355
|
await flushMacrotask();
|
|
343
356
|
|
|
344
357
|
expect(root.getValue().getState()).toMatchObject({
|
|
@@ -629,6 +642,70 @@ describe("McpServerResource connectionTimeout", () => {
|
|
|
629
642
|
describe("McpServerResource connection lifecycle", () => {
|
|
630
643
|
beforeEach(resetMocks);
|
|
631
644
|
|
|
645
|
+
it("publishes authorization URLs from the current connection", async () => {
|
|
646
|
+
const root = mount({ auth: { type: "oauth" } });
|
|
647
|
+
|
|
648
|
+
try {
|
|
649
|
+
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
|
+
|
|
657
|
+
expect(root.getValue().getState().authorizationUrl).toBe(
|
|
658
|
+
"https://auth.example.com/current",
|
|
659
|
+
);
|
|
660
|
+
} finally {
|
|
661
|
+
root.unmount();
|
|
662
|
+
}
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
it("ignores authorization URLs after disconnect", async () => {
|
|
666
|
+
const root = mount({ auth: { type: "oauth" } });
|
|
667
|
+
|
|
668
|
+
try {
|
|
669
|
+
await root.getValue().connect();
|
|
670
|
+
const provider = getOAuthProvider(0);
|
|
671
|
+
await root.getValue().disconnect();
|
|
672
|
+
|
|
673
|
+
await provider.redirectToAuthorization(
|
|
674
|
+
new URL("https://auth.example.com/stale"),
|
|
675
|
+
);
|
|
676
|
+
await flushMacrotask();
|
|
677
|
+
|
|
678
|
+
expect(root.getValue().getState()).toMatchObject({
|
|
679
|
+
connectionState: "disconnected",
|
|
680
|
+
authorizationUrl: null,
|
|
681
|
+
});
|
|
682
|
+
} finally {
|
|
683
|
+
root.unmount();
|
|
684
|
+
}
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
it("ignores authorization URLs from a superseded connection", async () => {
|
|
688
|
+
const root = mount({ auth: { type: "oauth" } });
|
|
689
|
+
|
|
690
|
+
try {
|
|
691
|
+
await root.getValue().connect();
|
|
692
|
+
const staleProvider = getOAuthProvider(0);
|
|
693
|
+
await root.getValue().connect();
|
|
694
|
+
|
|
695
|
+
await staleProvider.redirectToAuthorization(
|
|
696
|
+
new URL("https://auth.example.com/stale"),
|
|
697
|
+
);
|
|
698
|
+
await flushMacrotask();
|
|
699
|
+
|
|
700
|
+
expect(root.getValue().getState()).toMatchObject({
|
|
701
|
+
connectionState: "connected",
|
|
702
|
+
authorizationUrl: null,
|
|
703
|
+
});
|
|
704
|
+
} finally {
|
|
705
|
+
root.unmount();
|
|
706
|
+
}
|
|
707
|
+
});
|
|
708
|
+
|
|
632
709
|
it("replaces direct resource connections when the server id changes", async () => {
|
|
633
710
|
const storage = createStorage();
|
|
634
711
|
let updateId = (_id: string) => {};
|
|
@@ -744,6 +821,44 @@ describe("McpServerResource connection lifecycle", () => {
|
|
|
744
821
|
describe("McpServerResource completeAuth", () => {
|
|
745
822
|
beforeEach(resetMocks);
|
|
746
823
|
|
|
824
|
+
it("transfers authorization URL ownership when reusing the auth transport", async () => {
|
|
825
|
+
const storage = createStorage();
|
|
826
|
+
vi.mocked(storage.loadAuthState).mockResolvedValue({
|
|
827
|
+
serverUrl: "https://example.com/mcp",
|
|
828
|
+
state: "expected",
|
|
829
|
+
});
|
|
830
|
+
mocks.connectResults.push(() =>
|
|
831
|
+
Promise.reject(new UnauthorizedError("authorization required")),
|
|
832
|
+
);
|
|
833
|
+
const root = mount({ auth: { type: "oauth" }, storage });
|
|
834
|
+
|
|
835
|
+
try {
|
|
836
|
+
await root.getValue().connect();
|
|
837
|
+
await waitForResourceUpdate(
|
|
838
|
+
() => root.getValue().getState().connectionState === "authRequired",
|
|
839
|
+
);
|
|
840
|
+
const provider = getOAuthProvider(0);
|
|
841
|
+
|
|
842
|
+
await root
|
|
843
|
+
.getValue()
|
|
844
|
+
.completeAuth("https://example.com/callback?code=abc&state=expected");
|
|
845
|
+
await provider.redirectToAuthorization(
|
|
846
|
+
new URL("https://auth.example.com/reauthorize"),
|
|
847
|
+
);
|
|
848
|
+
await waitForResourceUpdate(
|
|
849
|
+
() => root.getValue().getState().authorizationUrl !== null,
|
|
850
|
+
);
|
|
851
|
+
|
|
852
|
+
expect(root.getValue().getState()).toMatchObject({
|
|
853
|
+
connectionState: "connected",
|
|
854
|
+
authorizationUrl: "https://auth.example.com/reauthorize",
|
|
855
|
+
});
|
|
856
|
+
expect(mocks.transports).toHaveLength(1);
|
|
857
|
+
} finally {
|
|
858
|
+
root.unmount();
|
|
859
|
+
}
|
|
860
|
+
});
|
|
861
|
+
|
|
747
862
|
it("lets callback validation win over mount-time auto-connect", async () => {
|
|
748
863
|
const pendingLoads: Array<(value: MCPPersistedAuthState | null) => void> =
|
|
749
864
|
[];
|
|
@@ -1474,7 +1589,7 @@ describe("McpServerResource elicitation", () => {
|
|
|
1474
1589
|
type: "object",
|
|
1475
1590
|
properties: {},
|
|
1476
1591
|
},
|
|
1477
|
-
{ signal: controller.signal },
|
|
1592
|
+
{ mcpReq: { signal: controller.signal } },
|
|
1478
1593
|
);
|
|
1479
1594
|
await waitForResourceUpdate(
|
|
1480
1595
|
() => root.getValue().getState().pendingElicitations.length === 1,
|
|
@@ -105,6 +105,9 @@ const useMcpServerResourceInstance = (
|
|
|
105
105
|
const pendingTransportRef = useRef<StreamableHTTPClientTransport | null>(
|
|
106
106
|
null,
|
|
107
107
|
);
|
|
108
|
+
const transportGenerationRef = useRef(
|
|
109
|
+
new WeakMap<StreamableHTTPClientTransport, { current: number }>(),
|
|
110
|
+
);
|
|
108
111
|
const connectionGenerationRef = useRef(0);
|
|
109
112
|
const pendingAuthValidationRef = useRef<{
|
|
110
113
|
count: number;
|
|
@@ -254,19 +257,29 @@ const useMcpServerResourceInstance = (
|
|
|
254
257
|
});
|
|
255
258
|
|
|
256
259
|
const buildTransport = useEffectEvent(
|
|
257
|
-
async (): Promise<StreamableHTTPClientTransport> => {
|
|
260
|
+
async (generation: number): Promise<StreamableHTTPClientTransport> => {
|
|
258
261
|
if (props.auth.type === "oauth") {
|
|
262
|
+
const generationOwner = { current: generation };
|
|
259
263
|
const authProvider = createOAuthProvider({
|
|
260
264
|
serverId: props.id,
|
|
261
265
|
serverUrl: props.url,
|
|
262
266
|
config: props.auth,
|
|
263
267
|
storage: props.storage,
|
|
264
268
|
redirectUri: props.redirectUri,
|
|
265
|
-
onAuthorizationUrl: (url) =>
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
+
onAuthorizationUrl: (url) => {
|
|
270
|
+
if (isCurrentConnection(generationOwner.current)) {
|
|
271
|
+
setAuthorizationUrl(url.toString());
|
|
272
|
+
}
|
|
273
|
+
},
|
|
269
274
|
});
|
|
275
|
+
const transport = new StreamableHTTPClientTransport(
|
|
276
|
+
new URL(props.url),
|
|
277
|
+
{
|
|
278
|
+
authProvider,
|
|
279
|
+
},
|
|
280
|
+
);
|
|
281
|
+
transportGenerationRef.current.set(transport, generationOwner);
|
|
282
|
+
return transport;
|
|
270
283
|
}
|
|
271
284
|
if (props.auth.type === "bearer") {
|
|
272
285
|
const { state, unbound } = await loadAuthState();
|
|
@@ -380,7 +393,7 @@ const useMcpServerResourceInstance = (
|
|
|
380
393
|
};
|
|
381
394
|
elicitationResolversRef.current.set(id, {
|
|
382
395
|
resolve,
|
|
383
|
-
signal: context.signal,
|
|
396
|
+
signal: context.mcpReq.signal,
|
|
384
397
|
onAbort,
|
|
385
398
|
requestedSchema,
|
|
386
399
|
});
|
|
@@ -395,10 +408,10 @@ const useMcpServerResourceInstance = (
|
|
|
395
408
|
]);
|
|
396
409
|
const entry = elicitationResolversRef.current.get(id);
|
|
397
410
|
if (entry) {
|
|
398
|
-
if (context.signal.aborted) {
|
|
411
|
+
if (context.mcpReq.signal.aborted) {
|
|
399
412
|
entry.onAbort();
|
|
400
413
|
} else {
|
|
401
|
-
context.signal.addEventListener("abort", entry.onAbort, {
|
|
414
|
+
context.mcpReq.signal.addEventListener("abort", entry.onAbort, {
|
|
402
415
|
once: true,
|
|
403
416
|
});
|
|
404
417
|
}
|
|
@@ -443,7 +456,7 @@ const useMcpServerResourceInstance = (
|
|
|
443
456
|
setTools([]);
|
|
444
457
|
let transport: StreamableHTTPClientTransport | null = null;
|
|
445
458
|
try {
|
|
446
|
-
transport = await buildTransport();
|
|
459
|
+
transport = await buildTransport(generation);
|
|
447
460
|
if (!isCurrentConnection(generation)) {
|
|
448
461
|
await closeQueuedTransports([transport]);
|
|
449
462
|
return;
|
|
@@ -536,12 +549,14 @@ const useMcpServerResourceInstance = (
|
|
|
536
549
|
try {
|
|
537
550
|
let transport = transportRef.current;
|
|
538
551
|
if (!transport) {
|
|
539
|
-
transport = await buildTransport();
|
|
552
|
+
transport = await buildTransport(generation);
|
|
540
553
|
if (!isCurrentConnection(generation)) {
|
|
541
554
|
await closeQueuedTransports([transport]);
|
|
542
555
|
throw createInterruptedAuthError();
|
|
543
556
|
}
|
|
544
557
|
}
|
|
558
|
+
const generationOwner = transportGenerationRef.current.get(transport);
|
|
559
|
+
if (generationOwner) generationOwner.current = generation;
|
|
545
560
|
transportRef.current = null;
|
|
546
561
|
clientRef.current = null;
|
|
547
562
|
pendingTransportRef.current = transport;
|
|
@@ -712,7 +727,7 @@ const useMcpServerResourceInstance = (
|
|
|
712
727
|
): readonly { property: string; message: string }[] | undefined => {
|
|
713
728
|
if (response.action === "accept") {
|
|
714
729
|
const entry = elicitationResolversRef.current.get(id);
|
|
715
|
-
if (!entry) return;
|
|
730
|
+
if (!entry) return undefined;
|
|
716
731
|
|
|
717
732
|
if (
|
|
718
733
|
typeof response.content !== "object" ||
|
|
@@ -752,10 +767,11 @@ const useMcpServerResourceInstance = (
|
|
|
752
767
|
content: response.content as ElicitResult["content"],
|
|
753
768
|
};
|
|
754
769
|
resolvePendingElicitation(id, result);
|
|
755
|
-
return;
|
|
770
|
+
return undefined;
|
|
756
771
|
}
|
|
757
772
|
|
|
758
773
|
resolvePendingElicitation(id, { action: response.action });
|
|
774
|
+
return undefined;
|
|
759
775
|
},
|
|
760
776
|
};
|
|
761
777
|
};
|