@assistant-ui/react-mcp 0.0.6 → 0.0.8

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.
Files changed (54) hide show
  1. package/dist/auth/createOAuthProvider.js.map +1 -1
  2. package/dist/context/McpConnectorByIndexProvider.js.map +1 -1
  3. package/dist/context/McpCustomServerByIndexProvider.js.map +1 -1
  4. package/dist/context/McpServerByIdProvider.js.map +1 -1
  5. package/dist/hooks/useMcpOAuthCallback.js +1 -1
  6. package/dist/hooks/useMcpOAuthCallback.js.map +1 -1
  7. package/dist/primitives/addForm/McpAddFormAuthSelect.js +1 -1
  8. package/dist/primitives/addForm/McpAddFormCancel.js +1 -1
  9. package/dist/primitives/addForm/McpAddFormError.js +1 -1
  10. package/dist/primitives/addForm/McpAddFormNameField.js +1 -1
  11. package/dist/primitives/addForm/McpAddFormRoot.js +1 -1
  12. package/dist/primitives/addForm/McpAddFormRoot.js.map +1 -1
  13. package/dist/primitives/addForm/McpAddFormSubmit.js +1 -1
  14. package/dist/primitives/addForm/McpAddFormUrlField.js +1 -1
  15. package/dist/primitives/addForm/context.js +1 -1
  16. package/dist/primitives/manager/McpManagerAddCustomTrigger.js +1 -1
  17. package/dist/primitives/manager/McpManagerConnectors.js +1 -1
  18. package/dist/primitives/manager/McpManagerConnectors.js.map +1 -1
  19. package/dist/primitives/manager/McpManagerCustomServers.js +1 -1
  20. package/dist/primitives/manager/McpManagerCustomServers.js.map +1 -1
  21. package/dist/primitives/manager/McpManagerRoot.js +1 -1
  22. package/dist/primitives/server/McpServerConnectButton.js +1 -1
  23. package/dist/primitives/server/McpServerConnectButton.js.map +1 -1
  24. package/dist/primitives/server/McpServerDisconnectButton.js +1 -1
  25. package/dist/primitives/server/McpServerDisconnectButton.js.map +1 -1
  26. package/dist/primitives/server/McpServerError.js +1 -1
  27. package/dist/primitives/server/McpServerIcon.js +1 -1
  28. package/dist/primitives/server/McpServerName.js +1 -1
  29. package/dist/primitives/server/McpServerOAuthLink.js +1 -1
  30. package/dist/primitives/server/McpServerRemoveButton.js +1 -1
  31. package/dist/primitives/server/McpServerRemoveButton.js.map +1 -1
  32. package/dist/primitives/server/McpServerRoot.js +1 -1
  33. package/dist/primitives/server/McpServerStatus.js +1 -1
  34. package/dist/primitives/server/McpServerToolName.js +1 -1
  35. package/dist/primitives/server/McpServerTools.js +1 -1
  36. package/dist/resources/McpManagerResource.d.ts.map +1 -1
  37. package/dist/resources/McpManagerResource.js +20 -18
  38. package/dist/resources/McpManagerResource.js.map +1 -1
  39. package/dist/resources/McpServerResource.d.ts.map +1 -1
  40. package/dist/resources/McpServerResource.js +17 -16
  41. package/dist/resources/McpServerResource.js.map +1 -1
  42. package/dist/resources/storage/McpCustomStorage.js +3 -1
  43. package/dist/resources/storage/McpCustomStorage.js.map +1 -1
  44. package/dist/resources/storage/McpLocalStorage.js +1 -1
  45. package/dist/resources/storage/McpLocalStorage.js.map +1 -1
  46. package/dist/resources/storage/McpMemoryStorage.js +1 -1
  47. package/dist/resources/storage/McpMemoryStorage.js.map +1 -1
  48. package/package.json +9 -9
  49. package/src/resources/McpManagerResource.ts +196 -203
  50. package/src/resources/McpServerResource.ts +228 -239
  51. package/src/resources/storage/McpCustomStorage.ts +5 -3
  52. package/src/resources/storage/McpLocalStorage.ts +49 -49
  53. package/src/resources/storage/McpMemoryStorage.ts +19 -17
  54. package/src/resources/storage/types.ts +1 -1
@@ -1,16 +1,8 @@
1
+ import { useState, useEffect, useMemo, useEffectEvent, useRef } from "react";
2
+ import { useResource, resource, withKey } from "@assistant-ui/tap";
1
3
  import {
2
- resource,
3
- tapState,
4
- tapResource,
5
- tapEffect,
6
- tapMemo,
7
- tapEffectEvent,
8
- tapRef,
9
- withKey,
10
- } from "@assistant-ui/tap";
11
- import {
12
- tapClientLookup,
13
- tapAssistantClientRef,
4
+ useClientLookup,
5
+ useAssistantClientRef,
14
6
  attachTransformScopes,
15
7
  type ClientOutput,
16
8
  } from "@assistant-ui/store";
@@ -40,208 +32,209 @@ function defaultRedirectUri(): string {
40
32
  return `${window.location.origin}/mcp/callback`;
41
33
  }
42
34
 
43
- export const McpManagerResource = resource(
44
- (props: McpManagerResourceProps): ClientOutput<"mcp"> => {
45
- const connectors = props.connectors ?? [];
46
- const autoConnect = props.autoConnect ?? true;
47
- const redirectUri = props.oauthRedirectUri ?? defaultRedirectUri();
48
-
49
- const storageElement = props.storage ?? McpLocalStorage();
50
- const storage = tapResource(storageElement);
51
-
52
- const [customServers, setCustomServers] = tapState<MCPCustomServerRecord[]>(
53
- [],
35
+ // Stable empty fallback so an absent `connectors` prop doesn't produce a fresh
36
+ // array each render (which would invalidate the serverElements memo below).
37
+ const NO_CONNECTORS: MCPConnector[] = [];
38
+
39
+ export const McpManagerResource = resource(function McpManagerResource(
40
+ props: McpManagerResourceProps,
41
+ ): ClientOutput<"mcp"> {
42
+ const connectors = props.connectors ?? NO_CONNECTORS;
43
+ const autoConnect = props.autoConnect ?? true;
44
+ const redirectUri = props.oauthRedirectUri ?? defaultRedirectUri();
45
+
46
+ const storageElement = props.storage ?? McpLocalStorage();
47
+ const storage = useResource(storageElement);
48
+
49
+ const [customServers, setCustomServers] = useState<MCPCustomServerRecord[]>(
50
+ [],
51
+ );
52
+ const [isHydrated, setIsHydrated] = useState(false);
53
+
54
+ const hydratedRef = useRef(false);
55
+
56
+ const hydrate = useEffectEvent(async (signal: { cancelled: boolean }) => {
57
+ try {
58
+ const records = await storage.loadCustomServers();
59
+ if (signal.cancelled) return;
60
+ // Merge rather than replace so any addCustomServer calls that
61
+ // happened before hydration resolved aren't silently overwritten.
62
+ // Persisted order wins; pre-hydration locals append.
63
+ setCustomServers((prev) => {
64
+ if (prev.length === 0) return records;
65
+ const persistedIds = new Set(records.map((r) => r.id));
66
+ return [...records, ...prev.filter((r) => !persistedIds.has(r.id))];
67
+ });
68
+ } finally {
69
+ if (!signal.cancelled) {
70
+ hydratedRef.current = true;
71
+ setIsHydrated(true);
72
+ }
73
+ }
74
+ });
75
+
76
+ useEffect(() => {
77
+ const signal = { cancelled: false };
78
+ void hydrate(signal);
79
+ return () => {
80
+ signal.cancelled = true;
81
+ };
82
+ }, []);
83
+
84
+ const persistCustomServers = useEffectEvent(
85
+ async (records: MCPCustomServerRecord[]) => {
86
+ if (!hydratedRef.current) return;
87
+ await storage.saveCustomServers(records);
88
+ },
89
+ );
90
+
91
+ useEffect(() => {
92
+ void persistCustomServers(customServers);
93
+ }, [customServers]);
94
+
95
+ const serverElements = useMemo(() => {
96
+ const connectorElements = connectors.map((c) =>
97
+ withKey(
98
+ c.id,
99
+ McpServerResource({
100
+ id: c.id,
101
+ kind: "connector",
102
+ name: c.name,
103
+ url: c.url,
104
+ icon: c.icon,
105
+ auth: c.auth,
106
+ storage,
107
+ redirectUri,
108
+ autoConnect,
109
+ onRemove: async () => {
110
+ // connectors cannot be removed
111
+ },
112
+ }),
113
+ ),
54
114
  );
55
- const [isHydrated, setIsHydrated] = tapState(false);
115
+ const customElements = customServers.map((s) =>
116
+ withKey(
117
+ s.id,
118
+ McpServerResource({
119
+ id: s.id,
120
+ kind: "custom",
121
+ name: s.name,
122
+ url: s.url,
123
+ auth: s.auth,
124
+ storage,
125
+ redirectUri,
126
+ autoConnect,
127
+ onRemove: async () => {
128
+ setCustomServers((prev) => prev.filter((x) => x.id !== s.id));
129
+ },
130
+ }),
131
+ ),
132
+ );
133
+ return [...connectorElements, ...customElements];
134
+ }, [connectors, customServers, storage, redirectUri, autoConnect]);
56
135
 
57
- const hydratedRef = tapRef(false);
136
+ const lookup = useClientLookup(() => serverElements, [serverElements]);
58
137
 
59
- const hydrate = tapEffectEvent(async (signal: { cancelled: boolean }) => {
60
- try {
61
- const records = await storage.loadCustomServers();
62
- if (signal.cancelled) return;
63
- // Merge rather than replace so any addCustomServer calls that
64
- // happened before hydration resolved aren't silently overwritten.
65
- // Persisted order wins; pre-hydration locals append.
66
- setCustomServers((prev) => {
67
- if (prev.length === 0) return records;
68
- const persistedIds = new Set(records.map((r) => r.id));
69
- return [...records, ...prev.filter((r) => !persistedIds.has(r.id))];
70
- });
71
- } finally {
72
- if (!signal.cancelled) {
73
- hydratedRef.current = true;
74
- setIsHydrated(true);
75
- }
138
+ const state = useMemo<MCPManagerState>(() => {
139
+ const all = lookup.state;
140
+ return {
141
+ servers: all,
142
+ connectors: all.filter((s) => s.kind === "connector"),
143
+ customServers: all.filter((s) => s.kind === "custom"),
144
+ isHydrated,
145
+ };
146
+ }, [lookup.state, isHydrated]);
147
+
148
+ // ─── Auto-register MCP tools as frontend tools in modelContext ─────
149
+ // Build the toolkit from connected servers; re-register when the visible
150
+ // tool surface changes. Tool names are prefixed with the server id to
151
+ // avoid collisions across connected servers — connector ids must not
152
+ // contain `__` (enforced by `defineConnector`); `addCustomServer`
153
+ // generates UUIDs that satisfy the constraint by construction.
154
+ const toolkit = useMemo<Record<string, Tool<any, any>>>(() => {
155
+ const out: Record<string, Tool<any, any>> = {};
156
+ for (const server of state.servers) {
157
+ if (server.connectionState !== "connected") continue;
158
+ for (const tool of server.tools) {
159
+ const fullName = `${server.id}__${tool.name}`;
160
+ out[fullName] = {
161
+ type: "frontend",
162
+ ...(tool.description !== undefined
163
+ ? { description: tool.description }
164
+ : {}),
165
+ parameters: tool.inputSchema as never,
166
+ execute: (args) =>
167
+ lookup.get({ key: server.id }).callTool(tool.name, args as unknown),
168
+ };
76
169
  }
77
- });
170
+ }
171
+ return out;
172
+ }, [state, lookup]);
78
173
 
79
- tapEffect(() => {
80
- const signal = { cancelled: false };
81
- void hydrate(signal);
82
- return () => {
83
- signal.cancelled = true;
84
- };
85
- }, []);
86
-
87
- const persistCustomServers = tapEffectEvent(
88
- async (records: MCPCustomServerRecord[]) => {
89
- if (!hydratedRef.current) return;
90
- await storage.saveCustomServers(records);
91
- },
92
- );
174
+ const clientRef = useAssistantClientRef();
93
175
 
94
- tapEffect(() => {
95
- void persistCustomServers(customServers);
96
- }, [customServers]);
97
-
98
- const serverElements = tapMemo(() => {
99
- const connectorElements = connectors.map((c) =>
100
- withKey(
101
- c.id,
102
- McpServerResource({
103
- id: c.id,
104
- kind: "connector",
105
- name: c.name,
106
- url: c.url,
107
- icon: c.icon,
108
- auth: c.auth,
109
- storage,
110
- redirectUri,
111
- autoConnect,
112
- onRemove: async () => {
113
- // connectors cannot be removed
114
- },
115
- }),
116
- ),
117
- );
118
- const customElements = customServers.map((s) =>
119
- withKey(
120
- s.id,
121
- McpServerResource({
122
- id: s.id,
123
- kind: "custom",
124
- name: s.name,
125
- url: s.url,
126
- auth: s.auth,
127
- storage,
128
- redirectUri,
129
- autoConnect,
130
- onRemove: async () => {
131
- setCustomServers((prev) => prev.filter((x) => x.id !== s.id));
132
- },
133
- }),
134
- ),
176
+ useEffect(() => {
177
+ const client = clientRef.current;
178
+ if (!client) return;
179
+ return client.modelContext().register({
180
+ getModelContext: () => ({ tools: toolkit }),
181
+ });
182
+ }, [toolkit, clientRef]);
183
+
184
+ const serverByKind = (kind: "connector" | "custom", index: number) => {
185
+ const list = kind === "connector" ? state.connectors : state.customServers;
186
+ const entry = list[index];
187
+ if (!entry) {
188
+ throw new Error(
189
+ `McpManagerResource: no ${kind} at index ${index} (length ${list.length})`,
135
190
  );
136
- return [...connectorElements, ...customElements];
137
- }, [connectors, customServers, storage, redirectUri, autoConnect]);
138
-
139
- const lookup = tapClientLookup(() => serverElements, [serverElements]);
140
-
141
- const state = tapMemo<MCPManagerState>(() => {
142
- const all = lookup.state;
143
- return {
144
- servers: all,
145
- connectors: all.filter((s) => s.kind === "connector"),
146
- customServers: all.filter((s) => s.kind === "custom"),
147
- isHydrated,
191
+ }
192
+ return lookup.get({ key: entry.id });
193
+ };
194
+
195
+ return {
196
+ getState: () => state,
197
+ server: (query) => {
198
+ if ("id" in query) return lookup.get({ key: query.id });
199
+ return serverByKind(query.kind, query.index);
200
+ },
201
+ connector: ({ index }) => serverByKind("connector", index),
202
+ customServer: ({ index }) => serverByKind("custom", index),
203
+ addCustomServer: async ({ name, url, auth }) => {
204
+ const record: MCPCustomServerRecord = {
205
+ id:
206
+ typeof crypto !== "undefined" && "randomUUID" in crypto
207
+ ? crypto.randomUUID()
208
+ : `mcp-${Date.now()}-${Math.random().toString(36).slice(2)}`,
209
+ name,
210
+ url,
211
+ auth: auth as MCPAuthConfig,
212
+ createdAt: Date.now(),
148
213
  };
149
- }, [lookup.state, isHydrated]);
150
-
151
- // ─── Auto-register MCP tools as frontend tools in modelContext ─────
152
- // Build the toolkit from connected servers; re-register when the visible
153
- // tool surface changes. Tool names are prefixed with the server id to
154
- // avoid collisions across connected servers — connector ids must not
155
- // contain `__` (enforced by `defineConnector`); `addCustomServer`
156
- // generates UUIDs that satisfy the constraint by construction.
157
- const toolkit = tapMemo<Record<string, Tool<any, any>>>(() => {
158
- const out: Record<string, Tool<any, any>> = {};
159
- for (const server of state.servers) {
160
- if (server.connectionState !== "connected") continue;
161
- for (const tool of server.tools) {
162
- const fullName = `${server.id}__${tool.name}`;
163
- out[fullName] = {
164
- type: "frontend",
165
- ...(tool.description !== undefined
166
- ? { description: tool.description }
167
- : {}),
168
- parameters: tool.inputSchema as never,
169
- execute: (args) =>
170
- lookup
171
- .get({ key: server.id })
172
- .callTool(tool.name, args as unknown),
173
- };
174
- }
175
- }
176
- return out;
177
- }, [state, lookup]);
178
-
179
- const clientRef = tapAssistantClientRef();
180
-
181
- tapEffect(() => {
182
- const client = clientRef.current;
183
- if (!client) return;
184
- return client.modelContext().register({
185
- getModelContext: () => ({ tools: toolkit }),
186
- });
187
- }, [toolkit, clientRef]);
188
-
189
- const serverByKind = (kind: "connector" | "custom", index: number) => {
190
- const list =
191
- kind === "connector" ? state.connectors : state.customServers;
192
- const entry = list[index];
193
- if (!entry) {
214
+ setCustomServers((prev) => [...prev, record]);
215
+ return record.id;
216
+ },
217
+ removeServer: async (id) => {
218
+ // removeServer is custom-server only — connectors are app-defined
219
+ // and not user-removable. Refuse rather than silently no-op.
220
+ if (state.connectors.some((c) => c.id === id)) {
194
221
  throw new Error(
195
- `McpManagerResource: no ${kind} at index ${index} (length ${list.length})`,
222
+ `Cannot remove connector "${id}" — connectors are app-defined and not removable. Use a custom server id instead.`,
196
223
  );
197
224
  }
198
- return lookup.get({ key: entry.id });
199
- };
200
-
201
- return {
202
- getState: () => state,
203
- server: (query) => {
204
- if ("id" in query) return lookup.get({ key: query.id });
205
- return serverByKind(query.kind, query.index);
206
- },
207
- connector: ({ index }) => serverByKind("connector", index),
208
- customServer: ({ index }) => serverByKind("custom", index),
209
- addCustomServer: async ({ name, url, auth }) => {
210
- const record: MCPCustomServerRecord = {
211
- id:
212
- typeof crypto !== "undefined" && "randomUUID" in crypto
213
- ? crypto.randomUUID()
214
- : `mcp-${Date.now()}-${Math.random().toString(36).slice(2)}`,
215
- name,
216
- url,
217
- auth: auth as MCPAuthConfig,
218
- createdAt: Date.now(),
219
- };
220
- setCustomServers((prev) => [...prev, record]);
221
- return record.id;
222
- },
223
- removeServer: async (id) => {
224
- // removeServer is custom-server only — connectors are app-defined
225
- // and not user-removable. Refuse rather than silently no-op.
226
- if (state.connectors.some((c) => c.id === id)) {
227
- throw new Error(
228
- `Cannot remove connector "${id}" — connectors are app-defined and not removable. Use a custom server id instead.`,
229
- );
230
- }
231
- // Delegate to McpServerResource.remove() which disconnects,
232
- // clears auth state, and unregisters from customServers in one
233
- // place. Fallback to manual cleanup if the lookup is empty
234
- // (server already gone).
235
- try {
236
- await lookup.get({ key: id }).remove();
237
- } catch {
238
- await storage.clearAuthState(id);
239
- setCustomServers((prev) => prev.filter((s) => s.id !== id));
240
- }
241
- },
242
- };
243
- },
244
- );
225
+ // Delegate to McpServerResource.remove() which disconnects,
226
+ // clears auth state, and unregisters from customServers in one
227
+ // place. Fallback to manual cleanup if the lookup is empty
228
+ // (server already gone).
229
+ try {
230
+ await lookup.get({ key: id }).remove();
231
+ } catch {
232
+ await storage.clearAuthState(id);
233
+ setCustomServers((prev) => prev.filter((s) => s.id !== id));
234
+ }
235
+ },
236
+ };
237
+ });
245
238
 
246
239
  // Ensure modelContext exists as a sibling when the manager mounts. If an
247
240
  // ancestor (e.g. a chat runtime) already provides modelContext, this is a