@assistant-ui/react-mcp 0.0.15 → 0.0.17

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.
@@ -15,6 +15,21 @@ const useMcpServerResource = (props) => {
15
15
  const [authorizationUrl, setAuthorizationUrl] = useState(null);
16
16
  const clientRef = useRef(null);
17
17
  const transportRef = useRef(null);
18
+ const withConnectionTimeout = useEffectEvent(async (promise, phase, startedAt) => {
19
+ const timeoutMs = props.connectionTimeout;
20
+ if (timeoutMs === void 0) return await promise;
21
+ const remainingMs = timeoutMs - (Date.now() - startedAt);
22
+ const timeoutError = () => /* @__PURE__ */ new Error(`MCP server "${props.id}" timed out while ${phase} after ${timeoutMs}ms.`);
23
+ if (remainingMs <= 0) throw timeoutError();
24
+ let timeout;
25
+ try {
26
+ return await Promise.race([promise, new Promise((_, reject) => {
27
+ timeout = setTimeout(() => reject(timeoutError()), remainingMs);
28
+ })]);
29
+ } finally {
30
+ if (timeout !== void 0) clearTimeout(timeout);
31
+ }
32
+ });
18
33
  const buildTransport = useEffectEvent(async () => {
19
34
  if (props.auth.type === "oauth") {
20
35
  const authProvider = createOAuthProvider({
@@ -40,8 +55,9 @@ const useMcpServerResource = (props) => {
40
55
  name: "assistant-ui-mcp",
41
56
  version: "0.0.0"
42
57
  });
43
- await client.connect(transport);
44
- const list = await client.listTools();
58
+ const startedAt_0 = Date.now();
59
+ await withConnectionTimeout(client.connect(transport), "connecting", startedAt_0);
60
+ const list = await withConnectionTimeout(client.listTools(), "listing tools", startedAt_0);
45
61
  clientRef.current = client;
46
62
  transportRef.current = transport;
47
63
  setTools(list.tools.map((t) => {
@@ -1 +1 @@
1
- {"version":3,"file":"McpServerResource.js","names":["useState","useRef","useEffect","useMemo","useEffectEvent","resource","ClientOutput","Client","StreamableHTTPClientTransport","StreamableHTTPClientTransportOptions","UnauthorizedError","Transport","createOAuthProvider","buildHeaders","assertValidServerId","MCPStorage","MCPAuthConfig","MCPConnectionState","MCPServerKind","MCPServerState","MCPToolInfo","McpServerResourceProps","id","kind","name","url","icon","auth","storage","redirectUri","autoConnect","onRemove","Promise","useMcpServerResource","props","connectionState","setConnectionState","tools","setTools","lastError","setLastError","message","authorizationUrl","setAuthorizationUrl","clientRef","transportRef","buildTransport","type","authProvider","serverId","config","onAuthorizationUrl","toString","URL","persisted","loadAuthState","headers","transportOpts","requestInit","finalizeConnect","transport","client","version","connect","list","listTools","current","map","t","info","inputSchema","description","undefined","closeTransport","close","doConnect","err","Error","String","doDisconnect","doCompleteAuth","callbackUrl","code","searchParams","get","finishAuth","tryAutoConnect","signal","cancelled","token","tokens","catch","state","getState","disconnect","remove","clearAuthState","callTool","args","arguments","Record","readResource","uri","completeAuth","McpServerResource"],"sources":["../../src/resources/McpServerResource.ts"],"sourcesContent":["import { useState, useRef, useEffect, useMemo, useEffectEvent } from \"react\";\nimport { resource } from \"@assistant-ui/tap\";\nimport type { ClientOutput } from \"@assistant-ui/store\";\nimport { Client } from \"@modelcontextprotocol/sdk/client/index.js\";\nimport {\n StreamableHTTPClientTransport,\n type StreamableHTTPClientTransportOptions,\n} from \"@modelcontextprotocol/sdk/client/streamableHttp.js\";\nimport { UnauthorizedError } from \"@modelcontextprotocol/sdk/client/auth.js\";\nimport type { Transport } from \"@modelcontextprotocol/sdk/shared/transport.js\";\nimport { createOAuthProvider } from \"../auth/createOAuthProvider\";\nimport { buildHeaders } from \"../auth/buildHeaders\";\nimport { assertValidServerId } from \"../utils/serverId\";\nimport type { MCPStorage } from \"./storage/types\";\nimport type {\n MCPAuthConfig,\n MCPConnectionState,\n MCPServerKind,\n MCPServerState,\n MCPToolInfo,\n} from \"../mcp-scope\";\n\nexport type McpServerResourceProps = {\n id: string;\n kind: MCPServerKind;\n name: string;\n url: string;\n icon?: string | undefined;\n auth: MCPAuthConfig;\n storage: MCPStorage;\n redirectUri: string;\n autoConnect: boolean;\n onRemove: () => Promise<void>;\n};\n\nconst useMcpServerResource = (\n props: McpServerResourceProps,\n): ClientOutput<\"mcpServer\"> => {\n assertValidServerId(props.id);\n const [connectionState, setConnectionState] =\n useState<MCPConnectionState>(\"disconnected\");\n const [tools, setTools] = useState<MCPToolInfo[]>([]);\n const [lastError, setLastError] = useState<{ message: string } | null>(null);\n const [authorizationUrl, setAuthorizationUrl] = useState<string | null>(null);\n\n const clientRef = useRef<Client | null>(null);\n const transportRef = useRef<StreamableHTTPClientTransport | null>(null);\n\n const buildTransport = useEffectEvent(\n async (): Promise<StreamableHTTPClientTransport> => {\n if (props.auth.type === \"oauth\") {\n const authProvider = createOAuthProvider({\n serverId: props.id,\n config: props.auth,\n storage: props.storage,\n redirectUri: props.redirectUri,\n onAuthorizationUrl: (url) => setAuthorizationUrl(url.toString()),\n });\n return new StreamableHTTPClientTransport(new URL(props.url), {\n authProvider,\n });\n }\n if (props.auth.type === \"bearer\") {\n const persisted = await props.storage.loadAuthState(props.id);\n const headers = buildHeaders(props.auth, persisted);\n const transportOpts: StreamableHTTPClientTransportOptions = {};\n if (headers) transportOpts.requestInit = { headers };\n return new StreamableHTTPClientTransport(\n new URL(props.url),\n transportOpts,\n );\n }\n return new StreamableHTTPClientTransport(new URL(props.url));\n },\n );\n\n const finalizeConnect = useEffectEvent(\n async (transport: StreamableHTTPClientTransport) => {\n const client = new Client({\n name: \"assistant-ui-mcp\",\n version: \"0.0.0\",\n });\n // SDK's StreamableHTTPClientTransport.sessionId is `string | undefined`\n // but Transport.sessionId is declared `string?` — under\n // exactOptionalPropertyTypes the SDK's own classes don't satisfy its\n // Transport interface. Cast to bridge the gap.\n await client.connect(transport as unknown as Transport);\n // Defer ref assignment until listTools() also succeeds — otherwise a\n // post-connect failure leaves stale refs that `callTool()` would\n // happily walk into, producing confusing SDK errors instead of\n // \"not connected\".\n const list = await client.listTools();\n clientRef.current = client;\n transportRef.current = transport;\n setTools(\n list.tools.map((t) => {\n const info: MCPToolInfo = {\n name: t.name,\n inputSchema: t.inputSchema,\n };\n if (t.description !== undefined) info.description = t.description;\n return info;\n }),\n );\n setConnectionState(\"connected\");\n },\n );\n\n const closeTransport = async () => {\n const t = transportRef.current;\n transportRef.current = null;\n clientRef.current = null;\n if (t) {\n try {\n await t.close();\n } catch {\n // ignore close errors\n }\n }\n };\n\n const doConnect = useEffectEvent(async () => {\n // Close any prior transport/client so a re-connect doesn't leak.\n await closeTransport();\n setConnectionState(\"connecting\");\n setLastError(null);\n setAuthorizationUrl(null);\n // Clear tools so a reconnect (error → connect, or authRequired\n // → connect) doesn't expose the previous attempt's tool list.\n setTools([]);\n let transport: StreamableHTTPClientTransport | null = null;\n try {\n transport = await buildTransport();\n // Don't assign to transportRef until connect succeeds — otherwise a\n // failed `listTools()` leaves an orphaned transport that future\n // doConnect / doDisconnect calls treat as live.\n await finalizeConnect(transport);\n } catch (err) {\n if (err instanceof UnauthorizedError) {\n // OAuth: keep the transport alive so completeAuth can call\n // finishAuth on it. Closing it before storing would leave a\n // closed transport on transportRef.\n transportRef.current = transport;\n setConnectionState(\"authRequired\");\n } else {\n if (transport) {\n try {\n await transport.close();\n } catch {\n // ignore close errors\n }\n }\n setLastError({\n message: err instanceof Error ? err.message : String(err),\n });\n setConnectionState(\"error\");\n }\n }\n });\n\n const doDisconnect = useEffectEvent(async () => {\n setTools([]);\n setAuthorizationUrl(null);\n setConnectionState(\"disconnected\");\n await closeTransport();\n });\n\n const doCompleteAuth = useEffectEvent(async (callbackUrl: string) => {\n setConnectionState(\"authPending\");\n setLastError(null);\n try {\n const url = new URL(callbackUrl);\n const code = url.searchParams.get(\"code\");\n if (!code) throw new Error(\"missing authorization code in callback URL\");\n let transport = transportRef.current;\n if (!transport) {\n transport = await buildTransport();\n transportRef.current = transport;\n }\n await transport.finishAuth(code);\n setAuthorizationUrl(null);\n await finalizeConnect(transport);\n } catch (err) {\n await closeTransport();\n setLastError({\n message: err instanceof Error ? err.message : String(err),\n });\n setConnectionState(\"error\");\n }\n });\n\n const tryAutoConnect = useEffectEvent(\n async (signal: { cancelled: boolean }) => {\n if (!props.autoConnect) return;\n if (props.auth.type === \"none\") {\n void doConnect();\n return;\n }\n // Static connector token short-circuits the storage read.\n if (props.auth.type === \"bearer\" && props.auth.token) {\n void doConnect();\n return;\n }\n const persisted = await props.storage.loadAuthState(props.id);\n if (signal.cancelled) return;\n if (props.auth.type === \"oauth\") {\n if (!persisted?.tokens) return;\n } else if (!persisted?.token) {\n return;\n }\n void doConnect();\n },\n );\n\n // Auto-connect on mount when usable auth exists.\n useEffect(() => {\n const signal = { cancelled: false };\n void tryAutoConnect(signal);\n return () => {\n signal.cancelled = true;\n const t = transportRef.current;\n transportRef.current = null;\n clientRef.current = null;\n if (t) t.close().catch(() => {});\n };\n }, []);\n\n const state = useMemo<MCPServerState>(\n () => ({\n id: props.id,\n kind: props.kind,\n name: props.name,\n url: props.url,\n icon: props.icon,\n connectionState,\n lastError,\n tools,\n authorizationUrl,\n }),\n [\n props.id,\n props.kind,\n props.name,\n props.url,\n props.icon,\n connectionState,\n lastError,\n tools,\n authorizationUrl,\n ],\n );\n\n return {\n getState: () => state,\n connect: doConnect,\n disconnect: doDisconnect,\n remove: async () => {\n await doDisconnect();\n await props.storage.clearAuthState(props.id);\n await props.onRemove();\n },\n callTool: async (name, args) => {\n const client = clientRef.current;\n if (!client) {\n throw new Error(`MCP server \"${props.id}\" is not connected`);\n }\n return await client.callTool({\n name,\n arguments: args as Record<string, unknown> | undefined,\n });\n },\n readResource: async (uri) => {\n const client = clientRef.current;\n if (!client) {\n throw new Error(`MCP server \"${props.id}\" is not connected`);\n }\n return await client.readResource({ uri });\n },\n completeAuth: doCompleteAuth,\n };\n};\n\nexport const McpServerResource = resource(useMcpServerResource);\n"],"mappings":"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"}
1
+ {"version":3,"file":"McpServerResource.js","names":["useState","useRef","useEffect","useMemo","useEffectEvent","resource","ClientOutput","Client","StreamableHTTPClientTransport","StreamableHTTPClientTransportOptions","UnauthorizedError","Transport","createOAuthProvider","buildHeaders","assertValidServerId","MCPStorage","MCPAuthConfig","MCPConnectionState","MCPServerKind","MCPServerState","MCPToolInfo","McpServerResourceProps","id","kind","name","url","icon","auth","storage","redirectUri","autoConnect","connectionTimeout","onRemove","Promise","useMcpServerResource","props","connectionState","setConnectionState","tools","setTools","lastError","setLastError","message","authorizationUrl","setAuthorizationUrl","clientRef","transportRef","withConnectionTimeout","promise","T","phase","startedAt","timeoutMs","undefined","remainingMs","Date","now","timeoutError","Error","timeout","ReturnType","setTimeout","race","_","reject","clearTimeout","buildTransport","type","authProvider","serverId","config","onAuthorizationUrl","toString","URL","persisted","loadAuthState","headers","transportOpts","requestInit","finalizeConnect","transport","client","version","connect","list","listTools","current","map","t","info","inputSchema","description","closeTransport","close","doConnect","err","String","doDisconnect","doCompleteAuth","callbackUrl","code","searchParams","get","finishAuth","tryAutoConnect","signal","cancelled","token","tokens","catch","state","getState","disconnect","remove","clearAuthState","callTool","args","arguments","Record","readResource","uri","completeAuth","McpServerResource"],"sources":["../../src/resources/McpServerResource.ts"],"sourcesContent":["import { useState, useRef, useEffect, useMemo, useEffectEvent } from \"react\";\nimport { resource } from \"@assistant-ui/tap\";\nimport type { ClientOutput } from \"@assistant-ui/store\";\nimport { Client } from \"@modelcontextprotocol/sdk/client/index.js\";\nimport {\n StreamableHTTPClientTransport,\n type StreamableHTTPClientTransportOptions,\n} from \"@modelcontextprotocol/sdk/client/streamableHttp.js\";\nimport { UnauthorizedError } from \"@modelcontextprotocol/sdk/client/auth.js\";\nimport type { Transport } from \"@modelcontextprotocol/sdk/shared/transport.js\";\nimport { createOAuthProvider } from \"../auth/createOAuthProvider\";\nimport { buildHeaders } from \"../auth/buildHeaders\";\nimport { assertValidServerId } from \"../utils/serverId\";\nimport type { MCPStorage } from \"./storage/types\";\nimport type {\n MCPAuthConfig,\n MCPConnectionState,\n MCPServerKind,\n MCPServerState,\n MCPToolInfo,\n} from \"../mcp-scope\";\n\nexport type McpServerResourceProps = {\n id: string;\n kind: MCPServerKind;\n name: string;\n url: string;\n icon?: string | undefined;\n auth: MCPAuthConfig;\n storage: MCPStorage;\n redirectUri: string;\n autoConnect: boolean;\n connectionTimeout?: number | undefined;\n onRemove: () => Promise<void>;\n};\n\nconst useMcpServerResource = (\n props: McpServerResourceProps,\n): ClientOutput<\"mcpServer\"> => {\n assertValidServerId(props.id);\n const [connectionState, setConnectionState] =\n useState<MCPConnectionState>(\"disconnected\");\n const [tools, setTools] = useState<MCPToolInfo[]>([]);\n const [lastError, setLastError] = useState<{ message: string } | null>(null);\n const [authorizationUrl, setAuthorizationUrl] = useState<string | null>(null);\n\n const clientRef = useRef<Client | null>(null);\n const transportRef = useRef<StreamableHTTPClientTransport | null>(null);\n\n const withConnectionTimeout = useEffectEvent(\n async <T>(\n promise: Promise<T>,\n phase: \"connecting\" | \"listing tools\",\n startedAt: number,\n ): Promise<T> => {\n const timeoutMs = props.connectionTimeout;\n if (timeoutMs === undefined) return await promise;\n const remainingMs = timeoutMs - (Date.now() - startedAt);\n const timeoutError = () =>\n new Error(\n `MCP server \"${props.id}\" timed out while ${phase} after ${timeoutMs}ms.`,\n );\n if (remainingMs <= 0) throw timeoutError();\n\n let timeout: ReturnType<typeof setTimeout> | undefined;\n try {\n return await Promise.race([\n promise,\n new Promise<never>((_, reject) => {\n timeout = setTimeout(() => reject(timeoutError()), remainingMs);\n }),\n ]);\n } finally {\n if (timeout !== undefined) clearTimeout(timeout);\n }\n },\n );\n\n const buildTransport = useEffectEvent(\n async (): Promise<StreamableHTTPClientTransport> => {\n if (props.auth.type === \"oauth\") {\n const authProvider = createOAuthProvider({\n serverId: props.id,\n config: props.auth,\n storage: props.storage,\n redirectUri: props.redirectUri,\n onAuthorizationUrl: (url) => setAuthorizationUrl(url.toString()),\n });\n return new StreamableHTTPClientTransport(new URL(props.url), {\n authProvider,\n });\n }\n if (props.auth.type === \"bearer\") {\n const persisted = await props.storage.loadAuthState(props.id);\n const headers = buildHeaders(props.auth, persisted);\n const transportOpts: StreamableHTTPClientTransportOptions = {};\n if (headers) transportOpts.requestInit = { headers };\n return new StreamableHTTPClientTransport(\n new URL(props.url),\n transportOpts,\n );\n }\n return new StreamableHTTPClientTransport(new URL(props.url));\n },\n );\n\n const finalizeConnect = useEffectEvent(\n async (transport: StreamableHTTPClientTransport) => {\n const client = new Client({\n name: \"assistant-ui-mcp\",\n version: \"0.0.0\",\n });\n const startedAt = Date.now();\n // SDK's StreamableHTTPClientTransport.sessionId is `string | undefined`\n // but Transport.sessionId is declared `string?` — under\n // exactOptionalPropertyTypes the SDK's own classes don't satisfy its\n // Transport interface. Cast to bridge the gap.\n await withConnectionTimeout(\n client.connect(transport as unknown as Transport),\n \"connecting\",\n startedAt,\n );\n // Defer ref assignment until listTools() also succeeds — otherwise a\n // post-connect failure leaves stale refs that `callTool()` would\n // happily walk into, producing confusing SDK errors instead of\n // \"not connected\".\n const list = await withConnectionTimeout(\n client.listTools(),\n \"listing tools\",\n startedAt,\n );\n clientRef.current = client;\n transportRef.current = transport;\n setTools(\n list.tools.map((t) => {\n const info: MCPToolInfo = {\n name: t.name,\n inputSchema: t.inputSchema,\n };\n if (t.description !== undefined) info.description = t.description;\n return info;\n }),\n );\n setConnectionState(\"connected\");\n },\n );\n\n const closeTransport = async () => {\n const t = transportRef.current;\n transportRef.current = null;\n clientRef.current = null;\n if (t) {\n try {\n await t.close();\n } catch {\n // ignore close errors\n }\n }\n };\n\n const doConnect = useEffectEvent(async () => {\n // Close any prior transport/client so a re-connect doesn't leak.\n await closeTransport();\n setConnectionState(\"connecting\");\n setLastError(null);\n setAuthorizationUrl(null);\n // Clear tools so a reconnect (error → connect, or authRequired\n // → connect) doesn't expose the previous attempt's tool list.\n setTools([]);\n let transport: StreamableHTTPClientTransport | null = null;\n try {\n transport = await buildTransport();\n // Don't assign to transportRef until connect succeeds — otherwise a\n // failed `listTools()` leaves an orphaned transport that future\n // doConnect / doDisconnect calls treat as live.\n await finalizeConnect(transport);\n } catch (err) {\n if (err instanceof UnauthorizedError) {\n // OAuth: keep the transport alive so completeAuth can call\n // finishAuth on it. Closing it before storing would leave a\n // closed transport on transportRef.\n transportRef.current = transport;\n setConnectionState(\"authRequired\");\n } else {\n if (transport) {\n try {\n await transport.close();\n } catch {\n // ignore close errors\n }\n }\n setLastError({\n message: err instanceof Error ? err.message : String(err),\n });\n setConnectionState(\"error\");\n }\n }\n });\n\n const doDisconnect = useEffectEvent(async () => {\n setTools([]);\n setAuthorizationUrl(null);\n setConnectionState(\"disconnected\");\n await closeTransport();\n });\n\n const doCompleteAuth = useEffectEvent(async (callbackUrl: string) => {\n setConnectionState(\"authPending\");\n setLastError(null);\n try {\n const url = new URL(callbackUrl);\n const code = url.searchParams.get(\"code\");\n if (!code) throw new Error(\"missing authorization code in callback URL\");\n let transport = transportRef.current;\n if (!transport) {\n transport = await buildTransport();\n transportRef.current = transport;\n }\n await transport.finishAuth(code);\n setAuthorizationUrl(null);\n await finalizeConnect(transport);\n } catch (err) {\n await closeTransport();\n setLastError({\n message: err instanceof Error ? err.message : String(err),\n });\n setConnectionState(\"error\");\n }\n });\n\n const tryAutoConnect = useEffectEvent(\n async (signal: { cancelled: boolean }) => {\n if (!props.autoConnect) return;\n if (props.auth.type === \"none\") {\n void doConnect();\n return;\n }\n // Static connector token short-circuits the storage read.\n if (props.auth.type === \"bearer\" && props.auth.token) {\n void doConnect();\n return;\n }\n const persisted = await props.storage.loadAuthState(props.id);\n if (signal.cancelled) return;\n if (props.auth.type === \"oauth\") {\n if (!persisted?.tokens) return;\n } else if (!persisted?.token) {\n return;\n }\n void doConnect();\n },\n );\n\n // Auto-connect on mount when usable auth exists.\n useEffect(() => {\n const signal = { cancelled: false };\n void tryAutoConnect(signal);\n return () => {\n signal.cancelled = true;\n const t = transportRef.current;\n transportRef.current = null;\n clientRef.current = null;\n if (t) t.close().catch(() => {});\n };\n }, []);\n\n const state = useMemo<MCPServerState>(\n () => ({\n id: props.id,\n kind: props.kind,\n name: props.name,\n url: props.url,\n icon: props.icon,\n connectionState,\n lastError,\n tools,\n authorizationUrl,\n }),\n [\n props.id,\n props.kind,\n props.name,\n props.url,\n props.icon,\n connectionState,\n lastError,\n tools,\n authorizationUrl,\n ],\n );\n\n return {\n getState: () => state,\n connect: doConnect,\n disconnect: doDisconnect,\n remove: async () => {\n await doDisconnect();\n await props.storage.clearAuthState(props.id);\n await props.onRemove();\n },\n callTool: async (name, args) => {\n const client = clientRef.current;\n if (!client) {\n throw new Error(`MCP server \"${props.id}\" is not connected`);\n }\n return await client.callTool({\n name,\n arguments: args as Record<string, unknown> | undefined,\n });\n },\n readResource: async (uri) => {\n const client = clientRef.current;\n if (!client) {\n throw new Error(`MCP server \"${props.id}\" is not connected`);\n }\n return await client.readResource({ uri });\n },\n completeAuth: doCompleteAuth,\n };\n};\n\nexport const McpServerResource = resource(useMcpServerResource);\n"],"mappings":"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"}
@@ -1,5 +1,6 @@
1
1
  //#region src/utils/serverId.d.ts
2
2
  declare function assertValidServerId(id: string): void;
3
+ declare function assertUniqueServerIds(ids: Iterable<string>): void;
3
4
  //#endregion
4
- export { assertValidServerId };
5
+ export { assertUniqueServerIds, assertValidServerId };
5
6
  //# sourceMappingURL=serverId.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"serverId.d.ts","names":[],"sources":["../../src/utils/serverId.ts"],"mappings":";iBAIgB,mBAAA,CAAoB,EAAU"}
1
+ {"version":3,"file":"serverId.d.ts","names":[],"sources":["../../src/utils/serverId.ts"],"mappings":";iBAIgB,mBAAA,CAAoB,EAAU;AAAA,iBAgB9B,qBAAA,CAAsB,GAAqB,EAAhB,QAAQ"}
@@ -4,7 +4,14 @@ function assertValidServerId(id) {
4
4
  if (id.includes("__")) throw new Error(`MCP server id "${id}" must not contain "__" — that sequence is reserved for separating server id from tool name.`);
5
5
  if (id.includes("")) throw new Error(`MCP server id "${id}" must not contain the ASCII Unit Separator (\\x1f) — it is reserved as the list delimiter in primitive id-list selectors.`);
6
6
  }
7
+ function assertUniqueServerIds(ids) {
8
+ const seen = /* @__PURE__ */ new Set();
9
+ for (const id of ids) {
10
+ if (seen.has(id)) throw new Error(`McpManagerResource received duplicate MCP server id "${id}". Server ids must be unique because they are used for lookups, OAuth routing, and tool name prefixes.`);
11
+ seen.add(id);
12
+ }
13
+ }
7
14
  //#endregion
8
- export { assertValidServerId };
15
+ export { assertUniqueServerIds, assertValidServerId };
9
16
 
10
17
  //# sourceMappingURL=serverId.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"serverId.js","names":["assertValidServerId","id","length","Error","includes"],"sources":["../../src/utils/serverId.ts"],"sourcesContent":["// Server ids are joined with tool names via `__` to form unique tool\n// names for the AI SDK. The separator must not appear inside ids,\n// otherwise two distinct `(serverId, toolName)` pairs could produce\n// the same composite name.\nexport function assertValidServerId(id: string): void {\n if (id.length === 0) {\n throw new Error(\"MCP server id must not be empty.\");\n }\n if (id.includes(\"__\")) {\n throw new Error(\n `MCP server id \"${id}\" must not contain \"__\" — that sequence is reserved for separating server id from tool name.`,\n );\n }\n if (id.includes(\"\\x1f\")) {\n throw new Error(\n `MCP server id \"${id}\" must not contain the ASCII Unit Separator (\\\\x1f) — it is reserved as the list delimiter in primitive id-list selectors.`,\n );\n }\n}\n"],"mappings":";AAIA,SAAgBA,oBAAoBC,IAAkB;CACpD,IAAIA,GAAGC,WAAW,GAChB,MAAM,IAAIC,MAAM,kCAAkC;CAEpD,IAAIF,GAAGG,SAAS,IAAI,GAClB,MAAM,IAAID,MACR,kBAAkBF,GAAE,6FACtB;CAEF,IAAIA,GAAGG,SAAS,GAAM,GACpB,MAAM,IAAID,MACR,kBAAkBF,GAAE,2HACtB;AAEJ"}
1
+ {"version":3,"file":"serverId.js","names":["assertValidServerId","id","length","Error","includes","assertUniqueServerIds","ids","Iterable","seen","Set","has","add"],"sources":["../../src/utils/serverId.ts"],"sourcesContent":["// Server ids are joined with tool names via `__` to form unique tool\n// names for the AI SDK. The separator must not appear inside ids,\n// otherwise two distinct `(serverId, toolName)` pairs could produce\n// the same composite name.\nexport function assertValidServerId(id: string): void {\n if (id.length === 0) {\n throw new Error(\"MCP server id must not be empty.\");\n }\n if (id.includes(\"__\")) {\n throw new Error(\n `MCP server id \"${id}\" must not contain \"__\" — that sequence is reserved for separating server id from tool name.`,\n );\n }\n if (id.includes(\"\\x1f\")) {\n throw new Error(\n `MCP server id \"${id}\" must not contain the ASCII Unit Separator (\\\\x1f) — it is reserved as the list delimiter in primitive id-list selectors.`,\n );\n }\n}\n\nexport function assertUniqueServerIds(ids: Iterable<string>): void {\n const seen = new Set<string>();\n for (const id of ids) {\n if (seen.has(id)) {\n throw new Error(\n `McpManagerResource received duplicate MCP server id \"${id}\". Server ids must be unique because they are used for lookups, OAuth routing, and tool name prefixes.`,\n );\n }\n seen.add(id);\n }\n}\n"],"mappings":";AAIA,SAAgBA,oBAAoBC,IAAkB;CACpD,IAAIA,GAAGC,WAAW,GAChB,MAAM,IAAIC,MAAM,kCAAkC;CAEpD,IAAIF,GAAGG,SAAS,IAAI,GAClB,MAAM,IAAID,MACR,kBAAkBF,GAAE,6FACtB;CAEF,IAAIA,GAAGG,SAAS,GAAM,GACpB,MAAM,IAAID,MACR,kBAAkBF,GAAE,2HACtB;AAEJ;AAEA,SAAgBI,sBAAsBC,KAA6B;CACjE,MAAME,uBAAO,IAAIC,IAAY;CAC7B,KAAK,MAAMR,MAAMK,KAAK;EACpB,IAAIE,KAAKE,IAAIT,EAAE,GACb,MAAM,IAAIE,MACR,wDAAwDF,GAAE,uGAC5D;EAEFO,KAAKG,IAAIV,EAAE;CACb;AACF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assistant-ui/react-mcp",
3
- "version": "0.0.15",
3
+ "version": "0.0.17",
4
4
  "description": "MCP server configuration and connection primitives for @assistant-ui",
5
5
  "keywords": [
6
6
  "mcp",
@@ -28,11 +28,11 @@
28
28
  "sideEffects": false,
29
29
  "dependencies": {
30
30
  "@modelcontextprotocol/sdk": "^1.29.0",
31
- "@radix-ui/react-primitive": "^2.1.5",
32
- "@assistant-ui/tap": "0.9.2",
33
- "assistant-stream": "0.3.23",
34
- "@assistant-ui/store": "0.2.18",
35
- "@assistant-ui/core": "0.2.18"
31
+ "@radix-ui/react-primitive": "^2.1.6",
32
+ "@assistant-ui/core": "0.2.20",
33
+ "@assistant-ui/store": "0.2.19",
34
+ "@assistant-ui/tap": "0.9.3",
35
+ "assistant-stream": "0.3.25"
36
36
  },
37
37
  "peerDependencies": {
38
38
  "@types/react": "*",
@@ -46,7 +46,8 @@
46
46
  "devDependencies": {
47
47
  "@types/react": "^19.2.17",
48
48
  "react": "^19.2.7",
49
- "@assistant-ui/x-buildutils": "0.0.15"
49
+ "vitest": "^4.1.9",
50
+ "@assistant-ui/x-buildutils": "0.0.17"
50
51
  },
51
52
  "publishConfig": {
52
53
  "access": "public",
@@ -62,6 +63,8 @@
62
63
  "url": "https://github.com/assistant-ui/assistant-ui/issues"
63
64
  },
64
65
  "scripts": {
65
- "build": "aui-build"
66
+ "build": "aui-build",
67
+ "test": "vitest run",
68
+ "test:watch": "vitest"
66
69
  }
67
70
  }
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
 
@@ -93,6 +95,7 @@ export type MCPManagerMethods = {
93
95
  name: string;
94
96
  url: string;
95
97
  auth: MCPAuthConfig;
98
+ connectionTimeout?: number | undefined;
96
99
  }) => Promise<string>;
97
100
  removeServer: (id: string) => Promise<void>;
98
101
  };
@@ -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
- }, [connectors, customServers, storage, redirectUri, autoConnect]);
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,221 @@
1
+ import { createTapRoot, useResource } from "@assistant-ui/tap";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import type { MCPStorage } from "./storage/types";
4
+
5
+ const mocks = vi.hoisted(() => {
6
+ const clients: any[] = [];
7
+ const transports: any[] = [];
8
+ const connectResults: Array<() => Promise<void>> = [];
9
+ const listToolsResults: Array<
10
+ () => Promise<{
11
+ tools: Array<{
12
+ name: string;
13
+ description?: string;
14
+ inputSchema: unknown;
15
+ }>;
16
+ }>
17
+ > = [];
18
+
19
+ const Client = vi.fn().mockImplementation(function Client(this: any) {
20
+ const index = clients.length;
21
+ this.connect = vi.fn(() => connectResults[index]?.() ?? Promise.resolve());
22
+ this.listTools = vi.fn(
23
+ () => listToolsResults[index]?.() ?? Promise.resolve({ tools: [] }),
24
+ );
25
+ this.callTool = vi.fn();
26
+ this.readResource = vi.fn();
27
+ clients.push(this);
28
+ });
29
+
30
+ const StreamableHTTPClientTransport = vi
31
+ .fn()
32
+ .mockImplementation(function StreamableHTTPClientTransport(this: any) {
33
+ this.close = vi.fn(() => Promise.resolve());
34
+ this.finishAuth = vi.fn(() => Promise.resolve());
35
+ transports.push(this);
36
+ });
37
+
38
+ return {
39
+ Client,
40
+ StreamableHTTPClientTransport,
41
+ clients,
42
+ transports,
43
+ connectResults,
44
+ listToolsResults,
45
+ };
46
+ });
47
+
48
+ vi.mock("@modelcontextprotocol/sdk/client/index.js", () => ({
49
+ Client: mocks.Client,
50
+ }));
51
+
52
+ vi.mock("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
53
+ StreamableHTTPClientTransport: mocks.StreamableHTTPClientTransport,
54
+ }));
55
+
56
+ const { McpServerResource } = await import("./McpServerResource");
57
+
58
+ const never = <T>() => new Promise<T>(() => {});
59
+
60
+ const tick = async () => {
61
+ await Promise.resolve();
62
+ };
63
+
64
+ const waitFor = async (predicate: () => boolean) => {
65
+ for (let i = 0; i < 20; i++) {
66
+ if (predicate()) return;
67
+ await tick();
68
+ }
69
+ expect(predicate()).toBe(true);
70
+ };
71
+
72
+ const createStorage = (): MCPStorage => ({
73
+ loadCustomServers: vi.fn(async () => []),
74
+ saveCustomServers: vi.fn(async () => {}),
75
+ loadAuthState: vi.fn(async () => null),
76
+ saveAuthState: vi.fn(async () => {}),
77
+ clearAuthState: vi.fn(async () => {}),
78
+ });
79
+
80
+ const mount = (props?: { connectionTimeout?: number | undefined }) => {
81
+ const connectionTimeout =
82
+ props && "connectionTimeout" in props ? props.connectionTimeout : 10_000;
83
+
84
+ return createTapRoot(function Root() {
85
+ return useResource(
86
+ McpServerResource({
87
+ id: "docs",
88
+ kind: "connector",
89
+ name: "Docs",
90
+ url: "https://example.com/mcp",
91
+ auth: { type: "none" },
92
+ storage: createStorage(),
93
+ redirectUri: "https://example.com/callback",
94
+ autoConnect: false,
95
+ connectionTimeout,
96
+ onRemove: vi.fn(async () => {}),
97
+ }),
98
+ );
99
+ });
100
+ };
101
+
102
+ describe("McpServerResource connectionTimeout", () => {
103
+ beforeEach(() => {
104
+ vi.useFakeTimers();
105
+ mocks.clients.length = 0;
106
+ mocks.transports.length = 0;
107
+ mocks.connectResults.length = 0;
108
+ mocks.listToolsResults.length = 0;
109
+ mocks.Client.mockClear();
110
+ mocks.StreamableHTTPClientTransport.mockClear();
111
+ });
112
+
113
+ afterEach(() => {
114
+ vi.useRealTimers();
115
+ });
116
+
117
+ it("fails the connection when client.connect hangs", async () => {
118
+ mocks.connectResults.push(() => never());
119
+ const root = mount();
120
+
121
+ try {
122
+ const connectPromise = root.getValue().connect();
123
+ await waitFor(() => mocks.clients[0]?.connect.mock.calls.length === 1);
124
+
125
+ await vi.advanceTimersByTimeAsync(10_000);
126
+ await connectPromise;
127
+
128
+ expect(root.getValue().getState()).toMatchObject({
129
+ connectionState: "error",
130
+ tools: [],
131
+ lastError: {
132
+ message:
133
+ 'MCP server "docs" timed out while connecting after 10000ms.',
134
+ },
135
+ });
136
+ expect(mocks.transports[0].close).toHaveBeenCalledTimes(1);
137
+ } finally {
138
+ root.unmount();
139
+ }
140
+ });
141
+
142
+ it("fails the connection when client.listTools hangs", async () => {
143
+ mocks.listToolsResults.push(() => never());
144
+ const root = mount();
145
+
146
+ try {
147
+ const connectPromise = root.getValue().connect();
148
+ await waitFor(() => mocks.clients[0]?.listTools.mock.calls.length === 1);
149
+
150
+ await vi.advanceTimersByTimeAsync(10_000);
151
+ await connectPromise;
152
+
153
+ expect(root.getValue().getState()).toMatchObject({
154
+ connectionState: "error",
155
+ tools: [],
156
+ lastError: {
157
+ message:
158
+ 'MCP server "docs" timed out while listing tools after 10000ms.',
159
+ },
160
+ });
161
+ expect(mocks.transports[0].close).toHaveBeenCalledTimes(1);
162
+ } finally {
163
+ root.unmount();
164
+ }
165
+ });
166
+
167
+ it("uses one timeout budget for connect and listTools", async () => {
168
+ mocks.connectResults.push(
169
+ () => new Promise<void>((resolve) => setTimeout(resolve, 9_000)),
170
+ );
171
+ mocks.listToolsResults.push(() => never());
172
+ const root = mount();
173
+
174
+ try {
175
+ const connectPromise = root.getValue().connect();
176
+ await waitFor(() => mocks.clients[0]?.connect.mock.calls.length === 1);
177
+
178
+ await vi.advanceTimersByTimeAsync(9_000);
179
+ await waitFor(() => mocks.clients[0]?.listTools.mock.calls.length === 1);
180
+ await vi.advanceTimersByTimeAsync(999);
181
+ await tick();
182
+ expect(root.getValue().getState().connectionState).toBe("connecting");
183
+
184
+ await vi.advanceTimersByTimeAsync(1);
185
+ await connectPromise;
186
+
187
+ expect(root.getValue().getState()).toMatchObject({
188
+ connectionState: "error",
189
+ tools: [],
190
+ lastError: {
191
+ message:
192
+ 'MCP server "docs" timed out while listing tools after 10000ms.',
193
+ },
194
+ });
195
+ expect(mocks.transports[0].close).toHaveBeenCalledTimes(1);
196
+ } finally {
197
+ root.unmount();
198
+ }
199
+ });
200
+
201
+ it("keeps waiting when connectionTimeout is undefined", async () => {
202
+ mocks.connectResults.push(() => never());
203
+ const root = mount({ connectionTimeout: undefined });
204
+
205
+ try {
206
+ void root.getValue().connect();
207
+ await waitFor(() => mocks.clients[0]?.connect.mock.calls.length === 1);
208
+ await vi.advanceTimersByTimeAsync(10_000);
209
+ await tick();
210
+
211
+ expect(root.getValue().getState()).toMatchObject({
212
+ connectionState: "connecting",
213
+ tools: [],
214
+ lastError: null,
215
+ });
216
+ expect(mocks.transports[0].close).not.toHaveBeenCalled();
217
+ } finally {
218
+ root.unmount();
219
+ }
220
+ });
221
+ });
@@ -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 client.connect(transport as unknown as Transport);
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 client.listTools();
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(
@@ -17,3 +17,15 @@ export function assertValidServerId(id: string): void {
17
17
  );
18
18
  }
19
19
  }
20
+
21
+ export function assertUniqueServerIds(ids: Iterable<string>): void {
22
+ const seen = new Set<string>();
23
+ for (const id of ids) {
24
+ if (seen.has(id)) {
25
+ throw new Error(
26
+ `McpManagerResource received duplicate MCP server id "${id}". Server ids must be unique because they are used for lookups, OAuth routing, and tool name prefixes.`,
27
+ );
28
+ }
29
+ seen.add(id);
30
+ }
31
+ }