@assistant-ui/react-mcp 0.0.16 → 0.0.18
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 +0 -1
- package/dist/auth/buildHeaders.d.ts.map +1 -1
- package/dist/auth/createOAuthProvider.d.ts +4 -3
- package/dist/auth/createOAuthProvider.d.ts.map +1 -1
- package/dist/auth/createOAuthProvider.js +2 -1
- package/dist/auth/createOAuthProvider.js.map +1 -1
- package/dist/auth/types.d.ts +2 -2
- package/dist/auth/types.d.ts.map +1 -1
- package/dist/connector.d.ts +0 -1
- package/dist/connector.d.ts.map +1 -1
- package/dist/context/McpConnectorByIndexProvider.d.ts +0 -1
- package/dist/context/McpConnectorByIndexProvider.d.ts.map +1 -1
- package/dist/context/McpCustomServerByIndexProvider.d.ts +0 -1
- package/dist/context/McpCustomServerByIndexProvider.d.ts.map +1 -1
- package/dist/context/McpServerByIdProvider.d.ts +0 -1
- package/dist/context/McpServerByIdProvider.d.ts.map +1 -1
- package/dist/hooks/useMcpOAuthCallback.d.ts +4 -3
- package/dist/hooks/useMcpOAuthCallback.d.ts.map +1 -1
- package/dist/hooks/useMcpOAuthCallback.js +14 -8
- package/dist/hooks/useMcpOAuthCallback.js.map +1 -1
- package/dist/mcp-scope.d.ts +19 -6
- package/dist/mcp-scope.d.ts.map +1 -1
- package/dist/primitives/addForm/McpAddFormAuthFields.d.ts +0 -1
- package/dist/primitives/addForm/McpAddFormAuthFields.d.ts.map +1 -1
- package/dist/primitives/addForm/McpAddFormAuthSelect.d.ts +0 -1
- package/dist/primitives/addForm/McpAddFormAuthSelect.d.ts.map +1 -1
- package/dist/primitives/addForm/McpAddFormCancel.d.ts +0 -1
- package/dist/primitives/addForm/McpAddFormCancel.d.ts.map +1 -1
- package/dist/primitives/addForm/McpAddFormError.d.ts +0 -1
- package/dist/primitives/addForm/McpAddFormError.d.ts.map +1 -1
- package/dist/primitives/addForm/McpAddFormNameField.d.ts +0 -1
- package/dist/primitives/addForm/McpAddFormNameField.d.ts.map +1 -1
- package/dist/primitives/addForm/McpAddFormRoot.d.ts +0 -1
- package/dist/primitives/addForm/McpAddFormRoot.d.ts.map +1 -1
- package/dist/primitives/addForm/McpAddFormSubmit.d.ts +0 -1
- package/dist/primitives/addForm/McpAddFormSubmit.d.ts.map +1 -1
- package/dist/primitives/addForm/McpAddFormUrlField.d.ts +0 -1
- package/dist/primitives/addForm/McpAddFormUrlField.d.ts.map +1 -1
- package/dist/primitives/addForm/context.d.ts +0 -1
- package/dist/primitives/addForm/context.d.ts.map +1 -1
- package/dist/primitives/addForm.d.ts +0 -2
- package/dist/primitives/manager/McpManagerAddCustomTrigger.d.ts +0 -1
- package/dist/primitives/manager/McpManagerAddCustomTrigger.d.ts.map +1 -1
- package/dist/primitives/manager/McpManagerConnectors.d.ts +0 -1
- package/dist/primitives/manager/McpManagerConnectors.d.ts.map +1 -1
- package/dist/primitives/manager/McpManagerCustomServers.d.ts +0 -1
- package/dist/primitives/manager/McpManagerCustomServers.d.ts.map +1 -1
- package/dist/primitives/manager/McpManagerRoot.d.ts +0 -1
- package/dist/primitives/manager/McpManagerRoot.d.ts.map +1 -1
- package/dist/primitives/manager.d.ts +0 -2
- package/dist/primitives/server/McpServerConnectButton.d.ts +0 -1
- package/dist/primitives/server/McpServerConnectButton.d.ts.map +1 -1
- package/dist/primitives/server/McpServerDisconnectButton.d.ts +0 -1
- package/dist/primitives/server/McpServerDisconnectButton.d.ts.map +1 -1
- package/dist/primitives/server/McpServerError.d.ts +0 -1
- package/dist/primitives/server/McpServerError.d.ts.map +1 -1
- package/dist/primitives/server/McpServerIcon.d.ts +4 -3
- package/dist/primitives/server/McpServerIcon.d.ts.map +1 -1
- package/dist/primitives/server/McpServerName.d.ts +0 -1
- package/dist/primitives/server/McpServerName.d.ts.map +1 -1
- package/dist/primitives/server/McpServerOAuthLink.d.ts +4 -3
- package/dist/primitives/server/McpServerOAuthLink.d.ts.map +1 -1
- package/dist/primitives/server/McpServerRemoveButton.d.ts +0 -1
- package/dist/primitives/server/McpServerRemoveButton.d.ts.map +1 -1
- package/dist/primitives/server/McpServerRoot.d.ts +0 -1
- package/dist/primitives/server/McpServerRoot.d.ts.map +1 -1
- package/dist/primitives/server/McpServerStatus.d.ts +0 -1
- package/dist/primitives/server/McpServerStatus.d.ts.map +1 -1
- package/dist/primitives/server/McpServerToolName.d.ts +0 -1
- package/dist/primitives/server/McpServerToolName.d.ts.map +1 -1
- package/dist/primitives/server/McpServerTools.d.ts +0 -1
- package/dist/primitives/server/McpServerTools.d.ts.map +1 -1
- package/dist/primitives/server.d.ts +0 -2
- package/dist/resources/McpManagerResource.d.ts +6 -3
- package/dist/resources/McpManagerResource.d.ts.map +1 -1
- package/dist/resources/McpManagerResource.js +122 -107
- package/dist/resources/McpManagerResource.js.map +1 -1
- package/dist/resources/McpServerResource.d.ts +1 -1
- package/dist/resources/McpServerResource.d.ts.map +1 -1
- package/dist/resources/McpServerResource.js +28 -5
- package/dist/resources/McpServerResource.js.map +1 -1
- package/dist/resources/storage/McpCustomStorage.d.ts +0 -1
- package/dist/resources/storage/McpCustomStorage.d.ts.map +1 -1
- package/dist/resources/storage/McpLocalStorage.d.ts +8 -3
- package/dist/resources/storage/McpLocalStorage.d.ts.map +1 -1
- package/dist/resources/storage/McpLocalStorage.js +55 -3
- package/dist/resources/storage/McpLocalStorage.js.map +1 -1
- package/dist/resources/storage/McpMemoryStorage.d.ts +0 -1
- package/dist/resources/storage/McpMemoryStorage.d.ts.map +1 -1
- package/dist/resources/storage/types.d.ts +0 -1
- package/dist/resources/storage/types.d.ts.map +1 -1
- package/dist/utils/serverId.d.ts +2 -1
- package/dist/utils/serverId.d.ts.map +1 -1
- package/dist/utils/serverId.js +8 -1
- package/dist/utils/serverId.js.map +1 -1
- package/package.json +11 -8
- package/src/hooks/useMcpOAuthCallback.test.ts +24 -0
- package/src/hooks/useMcpOAuthCallback.tsx +20 -5
- package/src/mcp-scope.ts +5 -0
- package/src/resources/McpManagerResource.test.ts +40 -0
- package/src/resources/McpManagerResource.ts +21 -2
- package/src/resources/McpServerResource.test.ts +364 -0
- package/src/resources/McpServerResource.ts +51 -3
- package/src/resources/storage/McpLocalStorage.test.ts +184 -0
- package/src/resources/storage/McpLocalStorage.ts +113 -3
- package/src/utils/serverId.ts +12 -0
|
@@ -2,6 +2,20 @@ import { type FC, type ReactNode, useEffect, useRef, useState } from "react";
|
|
|
2
2
|
import { useAui } from "@assistant-ui/store";
|
|
3
3
|
import { decodeServerIdFromState } from "../auth/createOAuthProvider";
|
|
4
4
|
|
|
5
|
+
export const createMcpOAuthCallbackError = (
|
|
6
|
+
err: unknown,
|
|
7
|
+
serverId: string | null,
|
|
8
|
+
): Error => {
|
|
9
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
10
|
+
if (serverId) {
|
|
11
|
+
return new Error(
|
|
12
|
+
`MCP OAuth callback for server "${serverId}" failed: ${message}`,
|
|
13
|
+
{ cause: err },
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
return new Error(`MCP OAuth callback failed: ${message}`, { cause: err });
|
|
17
|
+
};
|
|
18
|
+
|
|
5
19
|
export type UseMcpOAuthCallbackOptions = {
|
|
6
20
|
/** Defaults to `window.location.href`. */
|
|
7
21
|
url?: string;
|
|
@@ -39,27 +53,28 @@ export function useMcpOAuthCallback(
|
|
|
39
53
|
startedRef.current = url;
|
|
40
54
|
|
|
41
55
|
(async () => {
|
|
56
|
+
let serverId: string | null = null;
|
|
42
57
|
try {
|
|
43
58
|
const parsed = new URL(url);
|
|
44
59
|
const state = parsed.searchParams.get("state");
|
|
60
|
+
if (state) serverId = decodeServerIdFromState(state);
|
|
45
61
|
const error = parsed.searchParams.get("error");
|
|
46
62
|
if (error) {
|
|
47
63
|
throw new Error(
|
|
48
64
|
parsed.searchParams.get("error_description") ?? error,
|
|
49
65
|
);
|
|
50
66
|
}
|
|
51
|
-
if (!state) throw new Error(
|
|
52
|
-
const serverId = decodeServerIdFromState(state);
|
|
67
|
+
if (!state) throw new Error('missing "state" parameter');
|
|
53
68
|
if (!serverId) {
|
|
54
|
-
throw new Error("
|
|
69
|
+
throw new Error("state was not created by assistant-ui MCP");
|
|
55
70
|
}
|
|
56
71
|
setResult({ status: "running", serverId, error: null });
|
|
57
72
|
await aui.mcp().server({ id: serverId }).completeAuth(url);
|
|
58
73
|
setResult({ status: "done", serverId, error: null });
|
|
59
74
|
optsRef.current.onComplete?.(serverId);
|
|
60
75
|
} catch (err) {
|
|
61
|
-
const e = err
|
|
62
|
-
setResult(
|
|
76
|
+
const e = createMcpOAuthCallbackError(err, serverId);
|
|
77
|
+
setResult({ status: "error", serverId, error: e });
|
|
63
78
|
optsRef.current.onError?.(e);
|
|
64
79
|
}
|
|
65
80
|
})();
|
package/src/mcp-scope.ts
CHANGED
|
@@ -19,6 +19,7 @@ export type MCPConnector = {
|
|
|
19
19
|
url: string;
|
|
20
20
|
icon?: string | undefined;
|
|
21
21
|
auth: MCPAuthConfig;
|
|
22
|
+
connectionTimeout?: number | undefined;
|
|
22
23
|
};
|
|
23
24
|
|
|
24
25
|
export type MCPCustomServerRecord = {
|
|
@@ -26,6 +27,7 @@ export type MCPCustomServerRecord = {
|
|
|
26
27
|
name: string;
|
|
27
28
|
url: string;
|
|
28
29
|
auth: MCPAuthConfig;
|
|
30
|
+
connectionTimeout?: number | undefined;
|
|
29
31
|
createdAt: number;
|
|
30
32
|
};
|
|
31
33
|
|
|
@@ -70,6 +72,8 @@ export type MCPServerMethods = {
|
|
|
70
72
|
disconnect: () => Promise<void>;
|
|
71
73
|
remove: () => Promise<void>;
|
|
72
74
|
callTool: (name: string, args: unknown) => Promise<unknown>;
|
|
75
|
+
/** List resources exposed by the server. Returns the raw MCP `ListResourcesResult`. */
|
|
76
|
+
listResources: (params?: { cursor?: string | undefined }) => Promise<unknown>;
|
|
73
77
|
/** Read a resource by URI. Returns the raw MCP `ReadResourceResult`. */
|
|
74
78
|
readResource: (uri: string) => Promise<unknown>;
|
|
75
79
|
/** OAuth only: pass full callback URL (e.g. window.location.href) */
|
|
@@ -93,6 +97,7 @@ export type MCPManagerMethods = {
|
|
|
93
97
|
name: string;
|
|
94
98
|
url: string;
|
|
95
99
|
auth: MCPAuthConfig;
|
|
100
|
+
connectionTimeout?: number | undefined;
|
|
96
101
|
}) => Promise<string>;
|
|
97
102
|
removeServer: (id: string) => Promise<void>;
|
|
98
103
|
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createTapRoot, useResource } from "@assistant-ui/tap";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { defineConnector } from "../connector";
|
|
4
|
+
import type { MCPConnector } from "../mcp-scope";
|
|
5
|
+
import { assertUniqueServerIds } from "../utils/serverId";
|
|
6
|
+
import { McpManagerResource } from "./McpManagerResource";
|
|
7
|
+
import { McpMemoryStorage } from "./storage/McpMemoryStorage";
|
|
8
|
+
|
|
9
|
+
const connector = (id: string, name = id): MCPConnector =>
|
|
10
|
+
defineConnector({
|
|
11
|
+
id,
|
|
12
|
+
name,
|
|
13
|
+
url: `https://example.com/${id}/mcp`,
|
|
14
|
+
auth: { type: "none" },
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const mount = (connectors: MCPConnector[]) =>
|
|
18
|
+
createTapRoot(function Root() {
|
|
19
|
+
return useResource(
|
|
20
|
+
McpManagerResource({
|
|
21
|
+
connectors,
|
|
22
|
+
storage: McpMemoryStorage(),
|
|
23
|
+
autoConnect: false,
|
|
24
|
+
}),
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("McpManagerResource server ids", () => {
|
|
29
|
+
it("throws when connectors reuse an id", () => {
|
|
30
|
+
expect(() =>
|
|
31
|
+
mount([connector("docs", "Docs"), connector("docs", "Internal Docs")]),
|
|
32
|
+
).toThrow(
|
|
33
|
+
'McpManagerResource received duplicate MCP server id "docs". Server ids must be unique because they are used for lookups, OAuth routing, and tool name prefixes.',
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("allows distinct ids", () => {
|
|
38
|
+
expect(() => assertUniqueServerIds(["docs", "linear"])).not.toThrow();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -11,6 +11,7 @@ import type { Tool } from "assistant-stream";
|
|
|
11
11
|
import { McpServerResource } from "./McpServerResource";
|
|
12
12
|
import { McpLocalStorage } from "./storage/McpLocalStorage";
|
|
13
13
|
import type { MCPStorageElement } from "./storage/types";
|
|
14
|
+
import { assertUniqueServerIds } from "../utils/serverId";
|
|
14
15
|
import type {
|
|
15
16
|
MCPAuthConfig,
|
|
16
17
|
MCPConnector,
|
|
@@ -25,6 +26,8 @@ export type McpManagerResourceProps = {
|
|
|
25
26
|
oauthRedirectUri?: string | undefined;
|
|
26
27
|
/** Connect on mount when usable auth exists. Default true. */
|
|
27
28
|
autoConnect?: boolean | undefined;
|
|
29
|
+
/** Optional timeout in milliseconds for connect/listTools calls. Disabled by default. */
|
|
30
|
+
connectionTimeout?: number | undefined;
|
|
28
31
|
};
|
|
29
32
|
|
|
30
33
|
function defaultRedirectUri(): string {
|
|
@@ -42,6 +45,7 @@ const useMcpManagerResource = (
|
|
|
42
45
|
const connectors = props.connectors ?? NO_CONNECTORS;
|
|
43
46
|
const autoConnect = props.autoConnect ?? true;
|
|
44
47
|
const redirectUri = props.oauthRedirectUri ?? defaultRedirectUri();
|
|
48
|
+
const connectionTimeout = props.connectionTimeout;
|
|
45
49
|
|
|
46
50
|
const storageElement = props.storage ?? McpLocalStorage();
|
|
47
51
|
const storage = useResource(storageElement);
|
|
@@ -101,6 +105,11 @@ const useMcpManagerResource = (
|
|
|
101
105
|
}, [customServers]);
|
|
102
106
|
|
|
103
107
|
const serverElements = useMemo(() => {
|
|
108
|
+
assertUniqueServerIds([
|
|
109
|
+
...connectors.map((c) => c.id),
|
|
110
|
+
...customServers.map((s) => s.id),
|
|
111
|
+
]);
|
|
112
|
+
|
|
104
113
|
const connectorElements = connectors.map((c) =>
|
|
105
114
|
withKey(
|
|
106
115
|
c.id,
|
|
@@ -114,6 +123,7 @@ const useMcpManagerResource = (
|
|
|
114
123
|
storage,
|
|
115
124
|
redirectUri,
|
|
116
125
|
autoConnect,
|
|
126
|
+
connectionTimeout: c.connectionTimeout ?? connectionTimeout,
|
|
117
127
|
onRemove: async () => {
|
|
118
128
|
// connectors cannot be removed
|
|
119
129
|
},
|
|
@@ -132,6 +142,7 @@ const useMcpManagerResource = (
|
|
|
132
142
|
storage,
|
|
133
143
|
redirectUri,
|
|
134
144
|
autoConnect,
|
|
145
|
+
connectionTimeout: s.connectionTimeout ?? connectionTimeout,
|
|
135
146
|
onRemove: async () => {
|
|
136
147
|
setCustomServers((prev) => prev.filter((x) => x.id !== s.id));
|
|
137
148
|
},
|
|
@@ -139,7 +150,14 @@ const useMcpManagerResource = (
|
|
|
139
150
|
),
|
|
140
151
|
);
|
|
141
152
|
return [...connectorElements, ...customElements];
|
|
142
|
-
}, [
|
|
153
|
+
}, [
|
|
154
|
+
connectors,
|
|
155
|
+
customServers,
|
|
156
|
+
storage,
|
|
157
|
+
redirectUri,
|
|
158
|
+
autoConnect,
|
|
159
|
+
connectionTimeout,
|
|
160
|
+
]);
|
|
143
161
|
|
|
144
162
|
const lookup = useClientLookup(serverElements);
|
|
145
163
|
|
|
@@ -208,7 +226,7 @@ const useMcpManagerResource = (
|
|
|
208
226
|
},
|
|
209
227
|
connector: ({ index }) => serverByKind("connector", index),
|
|
210
228
|
customServer: ({ index }) => serverByKind("custom", index),
|
|
211
|
-
addCustomServer: async ({ name, url, auth }) => {
|
|
229
|
+
addCustomServer: async ({ name, url, auth, connectionTimeout }) => {
|
|
212
230
|
const record: MCPCustomServerRecord = {
|
|
213
231
|
id:
|
|
214
232
|
typeof crypto !== "undefined" && "randomUUID" in crypto
|
|
@@ -217,6 +235,7 @@ const useMcpManagerResource = (
|
|
|
217
235
|
name,
|
|
218
236
|
url,
|
|
219
237
|
auth: auth as MCPAuthConfig,
|
|
238
|
+
connectionTimeout,
|
|
220
239
|
createdAt: Date.now(),
|
|
221
240
|
};
|
|
222
241
|
setCustomServers((prev) => [...prev, record]);
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import { createTapRoot, useResource } from "@assistant-ui/tap";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import type { MCPAuthConfig } from "../mcp-scope";
|
|
4
|
+
import type { MCPStorage } from "./storage/types";
|
|
5
|
+
|
|
6
|
+
const mocks = vi.hoisted(() => {
|
|
7
|
+
const clients: any[] = [];
|
|
8
|
+
const transports: any[] = [];
|
|
9
|
+
const connectResults: Array<() => Promise<void>> = [];
|
|
10
|
+
const listToolsResults: Array<
|
|
11
|
+
() => Promise<{
|
|
12
|
+
tools: Array<{
|
|
13
|
+
name: string;
|
|
14
|
+
description?: string;
|
|
15
|
+
inputSchema: unknown;
|
|
16
|
+
}>;
|
|
17
|
+
}>
|
|
18
|
+
> = [];
|
|
19
|
+
const finishAuthResults: Array<() => Promise<void>> = [];
|
|
20
|
+
|
|
21
|
+
const Client = vi.fn().mockImplementation(function Client(this: any) {
|
|
22
|
+
const index = clients.length;
|
|
23
|
+
this.connect = vi.fn(() => connectResults[index]?.() ?? Promise.resolve());
|
|
24
|
+
this.listTools = vi.fn(
|
|
25
|
+
() => listToolsResults[index]?.() ?? Promise.resolve({ tools: [] }),
|
|
26
|
+
);
|
|
27
|
+
this.callTool = vi.fn();
|
|
28
|
+
this.listResources = vi.fn(() => Promise.resolve({ resources: [] }));
|
|
29
|
+
this.readResource = vi.fn();
|
|
30
|
+
clients.push(this);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const StreamableHTTPClientTransport = vi
|
|
34
|
+
.fn()
|
|
35
|
+
.mockImplementation(function StreamableHTTPClientTransport(this: any) {
|
|
36
|
+
const index = transports.length;
|
|
37
|
+
this.close = vi.fn(() => Promise.resolve());
|
|
38
|
+
this.finishAuth = vi.fn(
|
|
39
|
+
() => finishAuthResults[index]?.() ?? Promise.resolve(),
|
|
40
|
+
);
|
|
41
|
+
transports.push(this);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
Client,
|
|
46
|
+
StreamableHTTPClientTransport,
|
|
47
|
+
clients,
|
|
48
|
+
transports,
|
|
49
|
+
connectResults,
|
|
50
|
+
listToolsResults,
|
|
51
|
+
finishAuthResults,
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
vi.mock("@modelcontextprotocol/sdk/client/index.js", () => ({
|
|
56
|
+
Client: mocks.Client,
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
vi.mock("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
|
|
60
|
+
StreamableHTTPClientTransport: mocks.StreamableHTTPClientTransport,
|
|
61
|
+
}));
|
|
62
|
+
|
|
63
|
+
const { McpServerResource } = await import("./McpServerResource");
|
|
64
|
+
|
|
65
|
+
const never = <T>() => new Promise<T>(() => {});
|
|
66
|
+
|
|
67
|
+
const tick = async () => {
|
|
68
|
+
await Promise.resolve();
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const flushMacrotask = async () => {
|
|
72
|
+
await new Promise<void>((resolve) => {
|
|
73
|
+
const channel = new MessageChannel();
|
|
74
|
+
channel.port1.onmessage = () => {
|
|
75
|
+
channel.port1.close();
|
|
76
|
+
channel.port2.close();
|
|
77
|
+
resolve();
|
|
78
|
+
};
|
|
79
|
+
channel.port2.postMessage(null);
|
|
80
|
+
});
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const waitFor = async (predicate: () => boolean) => {
|
|
84
|
+
for (let i = 0; i < 20; i++) {
|
|
85
|
+
if (predicate()) return;
|
|
86
|
+
await tick();
|
|
87
|
+
}
|
|
88
|
+
expect(predicate()).toBe(true);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const createStorage = (): MCPStorage => ({
|
|
92
|
+
loadCustomServers: vi.fn(async () => []),
|
|
93
|
+
saveCustomServers: vi.fn(async () => {}),
|
|
94
|
+
loadAuthState: vi.fn(async () => null),
|
|
95
|
+
saveAuthState: vi.fn(async () => {}),
|
|
96
|
+
clearAuthState: vi.fn(async () => {}),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const resetMocks = () => {
|
|
100
|
+
mocks.clients.length = 0;
|
|
101
|
+
mocks.transports.length = 0;
|
|
102
|
+
mocks.connectResults.length = 0;
|
|
103
|
+
mocks.listToolsResults.length = 0;
|
|
104
|
+
mocks.finishAuthResults.length = 0;
|
|
105
|
+
mocks.Client.mockClear();
|
|
106
|
+
mocks.StreamableHTTPClientTransport.mockClear();
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const mount = (props?: {
|
|
110
|
+
auth?: MCPAuthConfig | undefined;
|
|
111
|
+
connectionTimeout?: number | undefined;
|
|
112
|
+
}) => {
|
|
113
|
+
const connectionTimeout =
|
|
114
|
+
props && "connectionTimeout" in props ? props.connectionTimeout : 10_000;
|
|
115
|
+
|
|
116
|
+
return createTapRoot(function Root() {
|
|
117
|
+
return useResource(
|
|
118
|
+
McpServerResource({
|
|
119
|
+
id: "docs",
|
|
120
|
+
kind: "connector",
|
|
121
|
+
name: "Docs",
|
|
122
|
+
url: "https://example.com/mcp",
|
|
123
|
+
auth: props?.auth ?? { type: "none" },
|
|
124
|
+
storage: createStorage(),
|
|
125
|
+
redirectUri: "https://example.com/callback",
|
|
126
|
+
autoConnect: false,
|
|
127
|
+
connectionTimeout,
|
|
128
|
+
onRemove: vi.fn(async () => {}),
|
|
129
|
+
}),
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
describe("McpServerResource connectionTimeout", () => {
|
|
135
|
+
beforeEach(() => {
|
|
136
|
+
vi.useFakeTimers();
|
|
137
|
+
resetMocks();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
afterEach(() => {
|
|
141
|
+
vi.useRealTimers();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("fails the connection when client.connect hangs", async () => {
|
|
145
|
+
mocks.connectResults.push(() => never());
|
|
146
|
+
const root = mount();
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
const connectPromise = root.getValue().connect();
|
|
150
|
+
await waitFor(() => mocks.clients[0]?.connect.mock.calls.length === 1);
|
|
151
|
+
|
|
152
|
+
await vi.advanceTimersByTimeAsync(10_000);
|
|
153
|
+
await connectPromise;
|
|
154
|
+
|
|
155
|
+
expect(root.getValue().getState()).toMatchObject({
|
|
156
|
+
connectionState: "error",
|
|
157
|
+
tools: [],
|
|
158
|
+
lastError: {
|
|
159
|
+
message:
|
|
160
|
+
'MCP server "docs" timed out while connecting after 10000ms.',
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
expect(mocks.transports[0].close).toHaveBeenCalledTimes(1);
|
|
164
|
+
} finally {
|
|
165
|
+
root.unmount();
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("fails the connection when client.listTools hangs", async () => {
|
|
170
|
+
mocks.listToolsResults.push(() => never());
|
|
171
|
+
const root = mount();
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
const connectPromise = root.getValue().connect();
|
|
175
|
+
await waitFor(() => mocks.clients[0]?.listTools.mock.calls.length === 1);
|
|
176
|
+
|
|
177
|
+
await vi.advanceTimersByTimeAsync(10_000);
|
|
178
|
+
await connectPromise;
|
|
179
|
+
|
|
180
|
+
expect(root.getValue().getState()).toMatchObject({
|
|
181
|
+
connectionState: "error",
|
|
182
|
+
tools: [],
|
|
183
|
+
lastError: {
|
|
184
|
+
message:
|
|
185
|
+
'MCP server "docs" timed out while listing tools after 10000ms.',
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
expect(mocks.transports[0].close).toHaveBeenCalledTimes(1);
|
|
189
|
+
} finally {
|
|
190
|
+
root.unmount();
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("uses one timeout budget for connect and listTools", async () => {
|
|
195
|
+
mocks.connectResults.push(
|
|
196
|
+
() => new Promise<void>((resolve) => setTimeout(resolve, 9_000)),
|
|
197
|
+
);
|
|
198
|
+
mocks.listToolsResults.push(() => never());
|
|
199
|
+
const root = mount();
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
const connectPromise = root.getValue().connect();
|
|
203
|
+
await waitFor(() => mocks.clients[0]?.connect.mock.calls.length === 1);
|
|
204
|
+
|
|
205
|
+
await vi.advanceTimersByTimeAsync(9_000);
|
|
206
|
+
await waitFor(() => mocks.clients[0]?.listTools.mock.calls.length === 1);
|
|
207
|
+
await vi.advanceTimersByTimeAsync(999);
|
|
208
|
+
await tick();
|
|
209
|
+
expect(root.getValue().getState().connectionState).toBe("connecting");
|
|
210
|
+
|
|
211
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
212
|
+
await connectPromise;
|
|
213
|
+
|
|
214
|
+
expect(root.getValue().getState()).toMatchObject({
|
|
215
|
+
connectionState: "error",
|
|
216
|
+
tools: [],
|
|
217
|
+
lastError: {
|
|
218
|
+
message:
|
|
219
|
+
'MCP server "docs" timed out while listing tools after 10000ms.',
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
expect(mocks.transports[0].close).toHaveBeenCalledTimes(1);
|
|
223
|
+
} finally {
|
|
224
|
+
root.unmount();
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("keeps waiting when connectionTimeout is undefined", async () => {
|
|
229
|
+
mocks.connectResults.push(() => never());
|
|
230
|
+
const root = mount({ connectionTimeout: undefined });
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
void root.getValue().connect();
|
|
234
|
+
await waitFor(() => mocks.clients[0]?.connect.mock.calls.length === 1);
|
|
235
|
+
await vi.advanceTimersByTimeAsync(10_000);
|
|
236
|
+
await tick();
|
|
237
|
+
|
|
238
|
+
expect(root.getValue().getState()).toMatchObject({
|
|
239
|
+
connectionState: "connecting",
|
|
240
|
+
tools: [],
|
|
241
|
+
lastError: null,
|
|
242
|
+
});
|
|
243
|
+
expect(mocks.transports[0].close).not.toHaveBeenCalled();
|
|
244
|
+
} finally {
|
|
245
|
+
root.unmount();
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe("McpServerResource completeAuth", () => {
|
|
251
|
+
beforeEach(resetMocks);
|
|
252
|
+
|
|
253
|
+
it("rejects when the callback URL has no authorization code", async () => {
|
|
254
|
+
const root = mount();
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
await expect(
|
|
258
|
+
root.getValue().completeAuth("https://example.com/callback?state=abc"),
|
|
259
|
+
).rejects.toThrow("missing authorization code in callback URL");
|
|
260
|
+
await flushMacrotask();
|
|
261
|
+
|
|
262
|
+
expect(root.getValue().getState()).toMatchObject({
|
|
263
|
+
connectionState: "error",
|
|
264
|
+
lastError: {
|
|
265
|
+
message: "missing authorization code in callback URL",
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
} finally {
|
|
269
|
+
root.unmount();
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("rejects after storing finishAuth failures on the server state", async () => {
|
|
274
|
+
mocks.finishAuthResults.push(() =>
|
|
275
|
+
Promise.reject(new Error("invalid_grant")),
|
|
276
|
+
);
|
|
277
|
+
const root = mount({ auth: { type: "oauth" } });
|
|
278
|
+
|
|
279
|
+
try {
|
|
280
|
+
await expect(
|
|
281
|
+
root.getValue().completeAuth("https://example.com/callback?code=abc"),
|
|
282
|
+
).rejects.toThrow("invalid_grant");
|
|
283
|
+
await flushMacrotask();
|
|
284
|
+
|
|
285
|
+
expect(root.getValue().getState()).toMatchObject({
|
|
286
|
+
connectionState: "error",
|
|
287
|
+
lastError: {
|
|
288
|
+
message: "invalid_grant",
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
expect(mocks.transports[0].finishAuth).toHaveBeenCalledWith("abc");
|
|
292
|
+
expect(mocks.transports[0].close).toHaveBeenCalledTimes(1);
|
|
293
|
+
} finally {
|
|
294
|
+
root.unmount();
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
describe("McpServerResource resource methods", () => {
|
|
300
|
+
beforeEach(() => {
|
|
301
|
+
mocks.clients.length = 0;
|
|
302
|
+
mocks.transports.length = 0;
|
|
303
|
+
mocks.connectResults.length = 0;
|
|
304
|
+
mocks.listToolsResults.length = 0;
|
|
305
|
+
mocks.Client.mockClear();
|
|
306
|
+
mocks.StreamableHTTPClientTransport.mockClear();
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("lists resources from a connected server", async () => {
|
|
310
|
+
const result = {
|
|
311
|
+
resources: [
|
|
312
|
+
{
|
|
313
|
+
uri: "docs://intro",
|
|
314
|
+
name: "Intro",
|
|
315
|
+
mimeType: "text/markdown",
|
|
316
|
+
},
|
|
317
|
+
],
|
|
318
|
+
};
|
|
319
|
+
const root = mount();
|
|
320
|
+
|
|
321
|
+
try {
|
|
322
|
+
await root.getValue().connect();
|
|
323
|
+
mocks.clients[0].listResources.mockResolvedValueOnce(result);
|
|
324
|
+
|
|
325
|
+
await expect(root.getValue().listResources()).resolves.toBe(result);
|
|
326
|
+
expect(mocks.clients[0].listResources).toHaveBeenCalledTimes(1);
|
|
327
|
+
} finally {
|
|
328
|
+
root.unmount();
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("forwards the resource pagination cursor", async () => {
|
|
333
|
+
const result = {
|
|
334
|
+
resources: [{ uri: "docs://page-two", name: "Page two" }],
|
|
335
|
+
};
|
|
336
|
+
const root = mount();
|
|
337
|
+
|
|
338
|
+
try {
|
|
339
|
+
await root.getValue().connect();
|
|
340
|
+
mocks.clients[0].listResources.mockResolvedValueOnce(result);
|
|
341
|
+
|
|
342
|
+
await expect(
|
|
343
|
+
root.getValue().listResources({ cursor: "next-page" }),
|
|
344
|
+
).resolves.toBe(result);
|
|
345
|
+
expect(mocks.clients[0].listResources).toHaveBeenCalledWith({
|
|
346
|
+
cursor: "next-page",
|
|
347
|
+
});
|
|
348
|
+
} finally {
|
|
349
|
+
root.unmount();
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("rejects listResources when the server is disconnected", async () => {
|
|
354
|
+
const root = mount();
|
|
355
|
+
|
|
356
|
+
try {
|
|
357
|
+
await expect(root.getValue().listResources()).rejects.toThrow(
|
|
358
|
+
'MCP server "docs" is not connected',
|
|
359
|
+
);
|
|
360
|
+
} finally {
|
|
361
|
+
root.unmount();
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
});
|
|
@@ -30,6 +30,7 @@ export type McpServerResourceProps = {
|
|
|
30
30
|
storage: MCPStorage;
|
|
31
31
|
redirectUri: string;
|
|
32
32
|
autoConnect: boolean;
|
|
33
|
+
connectionTimeout?: number | undefined;
|
|
33
34
|
onRemove: () => Promise<void>;
|
|
34
35
|
};
|
|
35
36
|
|
|
@@ -46,6 +47,35 @@ const useMcpServerResource = (
|
|
|
46
47
|
const clientRef = useRef<Client | null>(null);
|
|
47
48
|
const transportRef = useRef<StreamableHTTPClientTransport | null>(null);
|
|
48
49
|
|
|
50
|
+
const withConnectionTimeout = useEffectEvent(
|
|
51
|
+
async <T>(
|
|
52
|
+
promise: Promise<T>,
|
|
53
|
+
phase: "connecting" | "listing tools",
|
|
54
|
+
startedAt: number,
|
|
55
|
+
): Promise<T> => {
|
|
56
|
+
const timeoutMs = props.connectionTimeout;
|
|
57
|
+
if (timeoutMs === undefined) return await promise;
|
|
58
|
+
const remainingMs = timeoutMs - (Date.now() - startedAt);
|
|
59
|
+
const timeoutError = () =>
|
|
60
|
+
new Error(
|
|
61
|
+
`MCP server "${props.id}" timed out while ${phase} after ${timeoutMs}ms.`,
|
|
62
|
+
);
|
|
63
|
+
if (remainingMs <= 0) throw timeoutError();
|
|
64
|
+
|
|
65
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
66
|
+
try {
|
|
67
|
+
return await Promise.race([
|
|
68
|
+
promise,
|
|
69
|
+
new Promise<never>((_, reject) => {
|
|
70
|
+
timeout = setTimeout(() => reject(timeoutError()), remainingMs);
|
|
71
|
+
}),
|
|
72
|
+
]);
|
|
73
|
+
} finally {
|
|
74
|
+
if (timeout !== undefined) clearTimeout(timeout);
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
|
|
49
79
|
const buildTransport = useEffectEvent(
|
|
50
80
|
async (): Promise<StreamableHTTPClientTransport> => {
|
|
51
81
|
if (props.auth.type === "oauth") {
|
|
@@ -80,16 +110,25 @@ const useMcpServerResource = (
|
|
|
80
110
|
name: "assistant-ui-mcp",
|
|
81
111
|
version: "0.0.0",
|
|
82
112
|
});
|
|
113
|
+
const startedAt = Date.now();
|
|
83
114
|
// SDK's StreamableHTTPClientTransport.sessionId is `string | undefined`
|
|
84
115
|
// but Transport.sessionId is declared `string?` — under
|
|
85
116
|
// exactOptionalPropertyTypes the SDK's own classes don't satisfy its
|
|
86
117
|
// Transport interface. Cast to bridge the gap.
|
|
87
|
-
await
|
|
118
|
+
await withConnectionTimeout(
|
|
119
|
+
client.connect(transport as unknown as Transport),
|
|
120
|
+
"connecting",
|
|
121
|
+
startedAt,
|
|
122
|
+
);
|
|
88
123
|
// Defer ref assignment until listTools() also succeeds — otherwise a
|
|
89
124
|
// post-connect failure leaves stale refs that `callTool()` would
|
|
90
125
|
// happily walk into, producing confusing SDK errors instead of
|
|
91
126
|
// "not connected".
|
|
92
|
-
const list = await
|
|
127
|
+
const list = await withConnectionTimeout(
|
|
128
|
+
client.listTools(),
|
|
129
|
+
"listing tools",
|
|
130
|
+
startedAt,
|
|
131
|
+
);
|
|
93
132
|
clientRef.current = client;
|
|
94
133
|
transportRef.current = transport;
|
|
95
134
|
setTools(
|
|
@@ -182,10 +221,12 @@ const useMcpServerResource = (
|
|
|
182
221
|
await finalizeConnect(transport);
|
|
183
222
|
} catch (err) {
|
|
184
223
|
await closeTransport();
|
|
224
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
185
225
|
setLastError({
|
|
186
|
-
message:
|
|
226
|
+
message: error.message,
|
|
187
227
|
});
|
|
188
228
|
setConnectionState("error");
|
|
229
|
+
throw error;
|
|
189
230
|
}
|
|
190
231
|
});
|
|
191
232
|
|
|
@@ -269,6 +310,13 @@ const useMcpServerResource = (
|
|
|
269
310
|
arguments: args as Record<string, unknown> | undefined,
|
|
270
311
|
});
|
|
271
312
|
},
|
|
313
|
+
listResources: async (params) => {
|
|
314
|
+
const client = clientRef.current;
|
|
315
|
+
if (!client) {
|
|
316
|
+
throw new Error(`MCP server "${props.id}" is not connected`);
|
|
317
|
+
}
|
|
318
|
+
return await client.listResources(params);
|
|
319
|
+
},
|
|
272
320
|
readResource: async (uri) => {
|
|
273
321
|
const client = clientRef.current;
|
|
274
322
|
if (!client) {
|