shreyask commited on
Commit
d63a244
·
verified ·
1 Parent(s): aad94d8

add mcp support

Browse files
package-lock.json CHANGED
@@ -16,6 +16,7 @@
16
  "lucide-react": "^0.535.0",
17
  "react": "^19.1.0",
18
  "react-dom": "^19.1.0",
 
19
  "tailwindcss": "^4.1.11"
20
  },
21
  "devDependencies": {
@@ -4927,6 +4928,53 @@
4927
  "node": ">=0.10.0"
4928
  }
4929
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4930
  "node_modules/resolve-from": {
4931
  "version": "4.0.0",
4932
  "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -5144,6 +5192,12 @@
5144
  "node": ">= 18"
5145
  }
5146
  },
 
 
 
 
 
 
5147
  "node_modules/setprototypeof": {
5148
  "version": "1.2.0",
5149
  "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
 
16
  "lucide-react": "^0.535.0",
17
  "react": "^19.1.0",
18
  "react-dom": "^19.1.0",
19
+ "react-router-dom": "^7.8.0",
20
  "tailwindcss": "^4.1.11"
21
  },
22
  "devDependencies": {
 
4928
  "node": ">=0.10.0"
4929
  }
4930
  },
4931
+ "node_modules/react-router": {
4932
+ "version": "7.8.0",
4933
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.8.0.tgz",
4934
+ "integrity": "sha512-r15M3+LHKgM4SOapNmsH3smAizWds1vJ0Z9C4mWaKnT9/wD7+d/0jYcj6LmOvonkrO4Rgdyp4KQ/29gWN2i1eg==",
4935
+ "license": "MIT",
4936
+ "dependencies": {
4937
+ "cookie": "^1.0.1",
4938
+ "set-cookie-parser": "^2.6.0"
4939
+ },
4940
+ "engines": {
4941
+ "node": ">=20.0.0"
4942
+ },
4943
+ "peerDependencies": {
4944
+ "react": ">=18",
4945
+ "react-dom": ">=18"
4946
+ },
4947
+ "peerDependenciesMeta": {
4948
+ "react-dom": {
4949
+ "optional": true
4950
+ }
4951
+ }
4952
+ },
4953
+ "node_modules/react-router-dom": {
4954
+ "version": "7.8.0",
4955
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.8.0.tgz",
4956
+ "integrity": "sha512-ntInsnDVnVRdtSu6ODmTQ41cbluak/ENeTif7GBce0L6eztFg6/e1hXAysFQI8X25C8ipKmT9cClbJwxx3Kaqw==",
4957
+ "license": "MIT",
4958
+ "dependencies": {
4959
+ "react-router": "7.8.0"
4960
+ },
4961
+ "engines": {
4962
+ "node": ">=20.0.0"
4963
+ },
4964
+ "peerDependencies": {
4965
+ "react": ">=18",
4966
+ "react-dom": ">=18"
4967
+ }
4968
+ },
4969
+ "node_modules/react-router/node_modules/cookie": {
4970
+ "version": "1.0.2",
4971
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
4972
+ "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
4973
+ "license": "MIT",
4974
+ "engines": {
4975
+ "node": ">=18"
4976
+ }
4977
+ },
4978
  "node_modules/resolve-from": {
4979
  "version": "4.0.0",
4980
  "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
 
5192
  "node": ">= 18"
5193
  }
5194
  },
5195
+ "node_modules/set-cookie-parser": {
5196
+ "version": "2.7.1",
5197
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz",
5198
+ "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
5199
+ "license": "MIT"
5200
+ },
5201
  "node_modules/setprototypeof": {
5202
  "version": "1.2.0",
5203
  "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
package.json CHANGED
@@ -18,6 +18,7 @@
18
  "lucide-react": "^0.535.0",
19
  "react": "^19.1.0",
20
  "react-dom": "^19.1.0",
 
21
  "tailwindcss": "^4.1.11"
22
  },
23
  "devDependencies": {
 
18
  "lucide-react": "^0.535.0",
19
  "react": "^19.1.0",
20
  "react-dom": "^19.1.0",
21
+ "react-router-dom": "^7.8.0",
22
  "tailwindcss": "^4.1.11"
23
  },
24
  "devDependencies": {
src/App.tsx CHANGED
@@ -98,7 +98,7 @@ const App: React.FC = () => {
98
  const [isModelDropdownOpen, setIsModelDropdownOpen] =
99
  useState<boolean>(false);
100
  const [isMCPManagerOpen, setIsMCPManagerOpen] = useState<boolean>(false);
101
- const [isToolsPanelVisible, setIsToolsPanelVisible] = useState(false);
102
  const chatContainerRef = useRef<HTMLDivElement>(null);
103
  const debounceTimers = useRef<Record<number, NodeJS.Timeout>>({});
104
  const toolsContainerRef = useRef<HTMLDivElement>(null);
@@ -121,7 +121,6 @@ const App: React.FC = () => {
121
  } = useMCP();
122
 
123
  const loadTools = useCallback(async (): Promise<void> => {
124
- console.log("loadTools called - this should only happen on startup");
125
  const db = await getDB();
126
  const allTools: Tool[] = await db.getAll(STORE_NAME);
127
  if (allTools.length === 0) {
@@ -141,21 +140,10 @@ const App: React.FC = () => {
141
  } else {
142
  setTools(allTools.map((t) => ({ ...t, isCollapsed: false })));
143
  }
144
- }, []); // Remove the dependency that was causing the loop
145
 
146
- // Separate effect for MCP tools to avoid dependency cycles
147
- useEffect(() => {
148
- console.log(
149
- "MCP tools effect triggered - checking if this happens frequently"
150
- );
151
  const mcpTools = getMCPToolsAsOriginalTools();
152
- setTools((prevTools) => {
153
- // Remove old MCP tools and add new ones to avoid duplicates
154
- const nonMcpTools = prevTools.filter(
155
- (tool) => !tool.code?.includes("mcpServerId:")
156
- );
157
- return [...nonMcpTools, ...mcpTools];
158
- });
159
  }, [getMCPToolsAsOriginalTools]);
160
 
161
  useEffect(() => {
@@ -311,52 +299,8 @@ const App: React.FC = () => {
311
  });
312
 
313
  // Call MCP tool
314
- const mcpResult = await callMCPTool(serverId, toolName, args);
315
- console.log("Raw MCP result:", mcpResult);
316
-
317
- // Extract the actual content from MCP result
318
- if (mcpResult.content && mcpResult.content.length > 0) {
319
- // If there's text content, use that
320
- const textContent = mcpResult.content.find((c) => c.text);
321
- if (textContent && textContent.text) {
322
- console.log("Using text content:", textContent.text);
323
-
324
- // Try to handle double-encoded JSON
325
- let content = textContent.text;
326
- try {
327
- // First, try to parse as JSON to see if it's a JSON string
328
- const parsed = JSON.parse(content);
329
- if (typeof parsed === "string") {
330
- // If the parsed result is still a string, it was double-encoded
331
- console.log("Detected double-encoded JSON, parsing again");
332
- content = parsed;
333
- } else {
334
- // If it parsed to an object/array directly, use the original string
335
- console.log("Using original text content");
336
- }
337
- } catch {
338
- // If first parse fails, use original content
339
- console.log("Using original text content (parse failed)");
340
- }
341
-
342
- return content;
343
- }
344
-
345
- // Otherwise, return the first content item's data or the full content array
346
- const firstContent = mcpResult.content[0];
347
- if (firstContent.data !== undefined) {
348
- console.log("Using data content:", firstContent.data);
349
- return JSON.stringify(firstContent.data);
350
- }
351
-
352
- // Fallback to the full content array
353
- console.log("Using full content array:", mcpResult.content);
354
- return JSON.stringify(mcpResult.content);
355
- }
356
-
357
- // If no content, return the full result
358
- console.log("No content found, returning full result:", mcpResult);
359
- return JSON.stringify(mcpResult);
360
  }
361
  }
362
 
@@ -422,19 +366,11 @@ const App: React.FC = () => {
422
  ? extractFunctionAndRenderer(toolUsed.code)
423
  : { rendererCode: undefined };
424
 
425
- console.log("Tool renderer info:", {
426
- toolName: toolUsed?.name,
427
- hasRenderer: !!rendererCode,
428
- rendererCode,
429
- });
430
-
431
  let parsedResult;
432
  try {
433
  parsedResult = JSON.parse(result);
434
- console.log("Parsed result in executeToolCalls:", parsedResult);
435
  } catch {
436
  parsedResult = result;
437
- console.log("Failed to parse result, using as string:", result);
438
  }
439
 
440
  let namedParams: Record<string, unknown> = Object.create(null);
@@ -456,15 +392,6 @@ const App: React.FC = () => {
456
  renderer: rendererCode,
457
  input: namedParams,
458
  });
459
-
460
- console.log("RenderInfo created:", {
461
- call,
462
- result: parsedResult,
463
- renderer: rendererCode,
464
- hasRenderer: !!rendererCode,
465
- resultType: typeof parsedResult,
466
- isArray: Array.isArray(parsedResult),
467
- });
468
  } catch (error) {
469
  const errorMessage = getErrorMessage(error);
470
  results.push({ call, error: errorMessage });
@@ -867,9 +794,7 @@ const App: React.FC = () => {
867
  <ResultBlock error={info.error} />
868
  ) : (
869
  <ToolResultRenderer
870
- result={
871
- info.result as import("./types/mcp").MCPToolResult
872
- }
873
  rendererCode={info.renderer}
874
  input={info.input}
875
  />
 
98
  const [isModelDropdownOpen, setIsModelDropdownOpen] =
99
  useState<boolean>(false);
100
  const [isMCPManagerOpen, setIsMCPManagerOpen] = useState<boolean>(false);
101
+ const [isToolsPanelVisible, setIsToolsPanelVisible] = useState<boolean>(true);
102
  const chatContainerRef = useRef<HTMLDivElement>(null);
103
  const debounceTimers = useRef<Record<number, NodeJS.Timeout>>({});
104
  const toolsContainerRef = useRef<HTMLDivElement>(null);
 
121
  } = useMCP();
122
 
123
  const loadTools = useCallback(async (): Promise<void> => {
 
124
  const db = await getDB();
125
  const allTools: Tool[] = await db.getAll(STORE_NAME);
126
  if (allTools.length === 0) {
 
140
  } else {
141
  setTools(allTools.map((t) => ({ ...t, isCollapsed: false })));
142
  }
 
143
 
144
+ // Load MCP tools and merge them
 
 
 
 
145
  const mcpTools = getMCPToolsAsOriginalTools();
146
+ setTools((prevTools) => [...prevTools, ...mcpTools]);
 
 
 
 
 
 
147
  }, [getMCPToolsAsOriginalTools]);
148
 
149
  useEffect(() => {
 
299
  });
300
 
301
  // Call MCP tool
302
+ const result = await callMCPTool(serverId, toolName, args);
303
+ return JSON.stringify(result);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
304
  }
305
  }
306
 
 
366
  ? extractFunctionAndRenderer(toolUsed.code)
367
  : { rendererCode: undefined };
368
 
 
 
 
 
 
 
369
  let parsedResult;
370
  try {
371
  parsedResult = JSON.parse(result);
 
372
  } catch {
373
  parsedResult = result;
 
374
  }
375
 
376
  let namedParams: Record<string, unknown> = Object.create(null);
 
392
  renderer: rendererCode,
393
  input: namedParams,
394
  });
 
 
 
 
 
 
 
 
 
395
  } catch (error) {
396
  const errorMessage = getErrorMessage(error);
397
  results.push({ call, error: errorMessage });
 
794
  <ResultBlock error={info.error} />
795
  ) : (
796
  <ToolResultRenderer
797
+ result={info.result}
 
 
798
  rendererCode={info.renderer}
799
  input={info.input}
800
  />
src/components/MCPServerManager.tsx CHANGED
@@ -1,7 +1,9 @@
1
  import React, { useState } from "react";
 
2
  import { Plus, Server, Wifi, WifiOff, Trash2, TestTube } from "lucide-react";
3
  import { useMCP } from "../hooks/useMCP";
4
  import type { MCPServerConfig } from "../types/mcp";
 
5
 
6
  interface MCPServerManagerProps {
7
  isOpen: boolean;
@@ -24,6 +26,10 @@ export const MCPServerManager: React.FC<MCPServerManagerProps> = ({
24
  const [testingConnection, setTestingConnection] = useState<string | null>(
25
  null
26
  );
 
 
 
 
27
 
28
  const [newServer, setNewServer] = useState<Omit<MCPServerConfig, "id">>({
29
  name: "",
@@ -45,6 +51,10 @@ export const MCPServerManager: React.FC<MCPServerManagerProps> = ({
45
  id: `server_${Date.now()}`,
46
  };
47
 
 
 
 
 
48
  try {
49
  await addServer(serverConfig);
50
  setNewServer({
@@ -58,7 +68,11 @@ export const MCPServerManager: React.FC<MCPServerManagerProps> = ({
58
  });
59
  setShowAddForm(false);
60
  } catch (error) {
61
- console.error("Failed to add server:", error);
 
 
 
 
62
  }
63
  };
64
 
@@ -67,14 +81,16 @@ export const MCPServerManager: React.FC<MCPServerManagerProps> = ({
67
  try {
68
  const success = await testConnection(config);
69
  if (success) {
70
- alert("Connection test successful!");
71
  } else {
72
- alert("Connection test failed. Please check your configuration.");
73
  }
74
  } catch (error) {
75
- alert(`Connection test failed: ${error}`);
76
  } finally {
77
  setTestingConnection(null);
 
 
78
  }
79
  };
80
 
@@ -89,7 +105,11 @@ export const MCPServerManager: React.FC<MCPServerManagerProps> = ({
89
  await connectToServer(serverId);
90
  }
91
  } catch (error) {
92
- console.error("Failed to toggle connection:", error);
 
 
 
 
93
  }
94
  };
95
 
@@ -276,20 +296,45 @@ export const MCPServerManager: React.FC<MCPServerManagerProps> = ({
276
  {newServer.auth?.type === "oauth" && (
277
  <div>
278
  <label className="block text-sm font-medium text-gray-300 mb-1">
279
- OAuth Token
280
  </label>
281
- <input
282
- type="password"
283
- value={newServer.auth.token || ""}
284
- onChange={(e) =>
285
- setNewServer({
286
- ...newServer,
287
- auth: { ...newServer.auth!, token: e.target.value },
288
- })
289
- }
290
- className="w-full bg-gray-600 text-white rounded px-3 py-2"
291
- placeholder="your-oauth-token"
292
- />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
293
  </div>
294
  )}
295
 
@@ -337,25 +382,40 @@ export const MCPServerManager: React.FC<MCPServerManagerProps> = ({
337
  No MCP servers configured. Add one to get started!
338
  </div>
339
  ) : (
340
- Object.values(mcpState.servers).map((config) => (
341
  <div
342
- key={config.config.id}
343
- className="mb-4 p-2 border rounded-lg bg-gray-800"
344
  >
345
  <div className="flex items-center justify-between">
346
- <div>
347
- <span className="font-bold text-lg">
348
- {config.config.name}
349
- </span>
350
- <span className="ml-2 text-xs text-gray-400">
351
- {config.config.url}
352
- </span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
353
  </div>
354
- <div className="flex gap-2">
 
355
  {/* Test Connection */}
356
  <button
357
- onClick={() => handleTestConnection(config.config)}
358
- disabled={testingConnection === config.config.id}
359
  className="p-2 text-yellow-400 hover:text-yellow-300 disabled:opacity-50"
360
  title="Test Connection"
361
  >
@@ -366,18 +426,18 @@ export const MCPServerManager: React.FC<MCPServerManagerProps> = ({
366
  <button
367
  onClick={() =>
368
  handleToggleConnection(
369
- config.config.id,
370
- config.isConnected
371
  )
372
  }
373
  className={`p-2 ${
374
- config.isConnected
375
  ? "text-green-400 hover:text-green-300"
376
  : "text-gray-400 hover:text-gray-300"
377
  }`}
378
- title={config.isConnected ? "Disconnect" : "Connect"}
379
  >
380
- {config.isConnected ? (
381
  <Wifi size={16} />
382
  ) : (
383
  <WifiOff size={16} />
@@ -386,7 +446,7 @@ export const MCPServerManager: React.FC<MCPServerManagerProps> = ({
386
 
387
  {/* Remove Server */}
388
  <button
389
- onClick={() => removeServer(config.config.id)}
390
  className="p-2 text-red-400 hover:text-red-300"
391
  title="Remove Server"
392
  >
@@ -395,20 +455,20 @@ export const MCPServerManager: React.FC<MCPServerManagerProps> = ({
395
  </div>
396
  </div>
397
 
398
- {config.lastError && (
399
  <div className="mt-2 text-red-400 text-sm">
400
- Error: {config.lastError}
401
  </div>
402
  )}
403
 
404
- {config.isConnected && config.tools.length > 0 && (
405
  <div className="mt-3">
406
  <details className="text-sm">
407
  <summary className="text-gray-300 cursor-pointer">
408
- Available Tools ({config.tools.length})
409
  </summary>
410
  <div className="mt-2 space-y-1">
411
- {config.tools.map((tool) => (
412
  <div key={tool.name} className="text-gray-400 pl-4">
413
  • {tool.name} -{" "}
414
  {tool.description || "No description"}
@@ -428,6 +488,16 @@ export const MCPServerManager: React.FC<MCPServerManagerProps> = ({
428
  <strong>Error:</strong> {mcpState.error}
429
  </div>
430
  )}
 
 
 
 
 
 
 
 
 
 
431
  </div>
432
  </div>
433
  );
 
1
  import React, { useState } from "react";
2
+ import { discoverOAuthEndpoints, startOAuthFlow } from "../services/oauth";
3
  import { Plus, Server, Wifi, WifiOff, Trash2, TestTube } from "lucide-react";
4
  import { useMCP } from "../hooks/useMCP";
5
  import type { MCPServerConfig } from "../types/mcp";
6
+ import { STORAGE_KEYS, DEFAULTS } from "../config/constants";
7
 
8
  interface MCPServerManagerProps {
9
  isOpen: boolean;
 
26
  const [testingConnection, setTestingConnection] = useState<string | null>(
27
  null
28
  );
29
+ const [notification, setNotification] = useState<{
30
+ message: string;
31
+ type: 'success' | 'error';
32
+ } | null>(null);
33
 
34
  const [newServer, setNewServer] = useState<Omit<MCPServerConfig, "id">>({
35
  name: "",
 
51
  id: `server_${Date.now()}`,
52
  };
53
 
54
+ // Persist name and transport for OAuth flow
55
+ localStorage.setItem(STORAGE_KEYS.MCP_SERVER_NAME, newServer.name);
56
+ localStorage.setItem(STORAGE_KEYS.MCP_SERVER_TRANSPORT, newServer.transport);
57
+
58
  try {
59
  await addServer(serverConfig);
60
  setNewServer({
 
68
  });
69
  setShowAddForm(false);
70
  } catch (error) {
71
+ setNotification({
72
+ message: `Failed to add server: ${error instanceof Error ? error.message : 'Unknown error'}`,
73
+ type: 'error'
74
+ });
75
+ setTimeout(() => setNotification(null), DEFAULTS.OAUTH_ERROR_TIMEOUT);
76
  }
77
  };
78
 
 
81
  try {
82
  const success = await testConnection(config);
83
  if (success) {
84
+ setNotification({ message: "Connection test successful!", type: 'success' });
85
  } else {
86
+ setNotification({ message: "Connection test failed. Please check your configuration.", type: 'error' });
87
  }
88
  } catch (error) {
89
+ setNotification({ message: `Connection test failed: ${error}`, type: 'error' });
90
  } finally {
91
  setTestingConnection(null);
92
+ // Auto-hide notification after 3 seconds
93
+ setTimeout(() => setNotification(null), DEFAULTS.NOTIFICATION_TIMEOUT);
94
  }
95
  };
96
 
 
105
  await connectToServer(serverId);
106
  }
107
  } catch (error) {
108
+ setNotification({
109
+ message: `Failed to toggle connection: ${error instanceof Error ? error.message : 'Unknown error'}`,
110
+ type: 'error'
111
+ });
112
+ setTimeout(() => setNotification(null), DEFAULTS.OAUTH_ERROR_TIMEOUT);
113
  }
114
  };
115
 
 
296
  {newServer.auth?.type === "oauth" && (
297
  <div>
298
  <label className="block text-sm font-medium text-gray-300 mb-1">
299
+ OAuth Authorization
300
  </label>
301
+ <button
302
+ className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded mb-2"
303
+ type="button"
304
+ onClick={async () => {
305
+ try {
306
+ // Persist name and transport for OAuthCallback
307
+ localStorage.setItem(STORAGE_KEYS.MCP_SERVER_NAME, newServer.name);
308
+ localStorage.setItem(
309
+ STORAGE_KEYS.MCP_SERVER_TRANSPORT,
310
+ newServer.transport
311
+ );
312
+ const endpoints = await discoverOAuthEndpoints(
313
+ newServer.url
314
+ );
315
+ startOAuthFlow({
316
+ authorizationEndpoint:
317
+ endpoints.authorizationEndpoint,
318
+ clientId: endpoints.clientId,
319
+ redirectUri: endpoints.redirectUri,
320
+ scopes: endpoints.scopes,
321
+ });
322
+ } catch (err) {
323
+ setNotification({
324
+ message: "OAuth discovery failed: " +
325
+ (err instanceof Error ? err.message : String(err)),
326
+ type: 'error'
327
+ });
328
+ setTimeout(() => setNotification(null), DEFAULTS.OAUTH_ERROR_TIMEOUT);
329
+ }
330
+ }}
331
+ >
332
+ Connect with OAuth
333
+ </button>
334
+ <p className="text-xs text-gray-400">
335
+ You will be redirected to authorize this app with the MCP
336
+ server.
337
+ </p>
338
  </div>
339
  )}
340
 
 
382
  No MCP servers configured. Add one to get started!
383
  </div>
384
  ) : (
385
+ Object.values(mcpState.servers).map((connection) => (
386
  <div
387
+ key={connection.config.id}
388
+ className="bg-gray-700 rounded-lg p-4"
389
  >
390
  <div className="flex items-center justify-between">
391
+ <div className="flex items-center gap-3">
392
+ <div
393
+ className={`w-3 h-3 rounded-full ${
394
+ connection.isConnected ? "bg-green-400" : "bg-red-400"
395
+ }`}
396
+ />
397
+ <div>
398
+ <h4 className="text-white font-medium">
399
+ {connection.config.name}
400
+ </h4>
401
+ <p className="text-gray-400 text-sm">
402
+ {connection.config.url}
403
+ </p>
404
+ <p className="text-gray-500 text-xs">
405
+ Transport: {connection.config.transport}
406
+ {connection.config.auth &&
407
+ ` • Auth: ${connection.config.auth.type}`}
408
+ {connection.isConnected &&
409
+ ` • ${connection.tools.length} tools available`}
410
+ </p>
411
+ </div>
412
  </div>
413
+
414
+ <div className="flex items-center gap-2">
415
  {/* Test Connection */}
416
  <button
417
+ onClick={() => handleTestConnection(connection.config)}
418
+ disabled={testingConnection === connection.config.id}
419
  className="p-2 text-yellow-400 hover:text-yellow-300 disabled:opacity-50"
420
  title="Test Connection"
421
  >
 
426
  <button
427
  onClick={() =>
428
  handleToggleConnection(
429
+ connection.config.id,
430
+ connection.isConnected
431
  )
432
  }
433
  className={`p-2 ${
434
+ connection.isConnected
435
  ? "text-green-400 hover:text-green-300"
436
  : "text-gray-400 hover:text-gray-300"
437
  }`}
438
+ title={connection.isConnected ? "Disconnect" : "Connect"}
439
  >
440
+ {connection.isConnected ? (
441
  <Wifi size={16} />
442
  ) : (
443
  <WifiOff size={16} />
 
446
 
447
  {/* Remove Server */}
448
  <button
449
+ onClick={() => removeServer(connection.config.id)}
450
  className="p-2 text-red-400 hover:text-red-300"
451
  title="Remove Server"
452
  >
 
455
  </div>
456
  </div>
457
 
458
+ {connection.lastError && (
459
  <div className="mt-2 text-red-400 text-sm">
460
+ Error: {connection.lastError}
461
  </div>
462
  )}
463
 
464
+ {connection.isConnected && connection.tools.length > 0 && (
465
  <div className="mt-3">
466
  <details className="text-sm">
467
  <summary className="text-gray-300 cursor-pointer">
468
+ Available Tools ({connection.tools.length})
469
  </summary>
470
  <div className="mt-2 space-y-1">
471
+ {connection.tools.map((tool) => (
472
  <div key={tool.name} className="text-gray-400 pl-4">
473
  • {tool.name} -{" "}
474
  {tool.description || "No description"}
 
488
  <strong>Error:</strong> {mcpState.error}
489
  </div>
490
  )}
491
+
492
+ {notification && (
493
+ <div className={`mt-4 p-4 border rounded-lg ${
494
+ notification.type === 'success'
495
+ ? 'bg-green-900 border-green-700 text-green-200'
496
+ : 'bg-red-900 border-red-700 text-red-200'
497
+ }`}>
498
+ {notification.message}
499
+ </div>
500
+ )}
501
  </div>
502
  </div>
503
  );
src/components/ResultBlock.tsx CHANGED
@@ -1,144 +1,28 @@
1
  import type React from "react";
2
- import { useState } from "react";
3
 
4
- type JsonValue = string | number | boolean | null | JsonObject | JsonArray;
5
- type JsonObject = { [key: string]: JsonValue };
6
- type JsonArray = JsonValue[];
 
7
 
8
- const isJsonString = (str: string): boolean => {
9
- if (typeof str !== "string") return false;
10
- const trimmed = str.trim();
11
-
12
- // Check if it looks like JSON (starts with { or [)
13
- if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return false;
14
-
15
- try {
16
- const parsed = JSON.parse(trimmed);
17
- // Ensure it's actually an object or array, not just a primitive
18
- console.log("JSON parse successful:", parsed);
19
- return typeof parsed === "object" && parsed !== null;
20
- } catch (error) {
21
- console.log("JSON parse failed:", error);
22
- return false;
23
- }
24
- };
25
-
26
- const JsonCollapsible: React.FC<{ data: JsonValue; isString?: boolean }> = ({
27
- data,
28
- isString = false,
29
- }) => {
30
- const [isCollapsed, setIsCollapsed] = useState(true);
31
-
32
- let jsonData: JsonValue;
33
- try {
34
- jsonData = isString ? JSON.parse(data as string) : data;
35
- } catch {
36
- // If parsing fails, display as string
37
- return (
38
- <pre className="text-sm text-gray-300 whitespace-pre-wrap overflow-auto">
39
- {String(data)}
40
- </pre>
41
- );
42
- }
43
-
44
- const isObject = typeof jsonData === "object" && jsonData !== null;
45
-
46
- if (!isObject) {
47
- return <span className="text-gray-300">{JSON.stringify(jsonData)}</span>;
48
- }
49
-
50
- const keys = Object.keys(jsonData as JsonObject);
51
- const preview = Array.isArray(jsonData)
52
- ? `[${jsonData.length} items]`
53
- : `{${keys.length} keys}`;
54
-
55
- return (
56
- <div className="json-collapsible">
57
- <button
58
- onClick={() => setIsCollapsed(!isCollapsed)}
59
- className="flex items-center gap-1 text-blue-400 hover:text-blue-300 transition-colors text-sm font-mono"
60
- >
61
- <span
62
- className="transform transition-transform duration-200"
63
- style={{
64
- transform: isCollapsed ? "rotate(-90deg)" : "rotate(0deg)",
65
- }}
66
- >
67
-
68
- </span>
69
- {isCollapsed ? preview : Array.isArray(jsonData) ? "[" : "{"}
70
- </button>
71
- {!isCollapsed && (
72
- <div className="ml-4 mt-1">
73
- <pre className="text-sm text-gray-300 whitespace-pre-wrap overflow-auto">
74
- {JSON.stringify(jsonData, null, 2)}
75
- </pre>
76
- <div className="text-blue-400 font-mono text-sm">
77
- {Array.isArray(jsonData) ? "]" : "}"}
78
- </div>
79
- </div>
80
- )}
81
- </div>
82
- );
83
- };
84
-
85
- const ResultBlock: React.FC<{ error?: string; result?: unknown }> = ({
86
  error,
87
  result,
88
- }) => {
89
- console.log("ResultBlock component rendered with:", {
90
- error,
91
- result,
92
- type: typeof result,
93
- });
94
-
95
- const renderContent = () => {
96
- console.log("ResultBlock Debug:", {
97
- result,
98
- type: typeof result,
99
- isString: typeof result === "string",
100
- isArray: Array.isArray(result),
101
- startsWithBracket:
102
- typeof result === "string" && result.trim().startsWith("["),
103
- isJsonString: typeof result === "string" ? isJsonString(result) : false,
104
- });
105
-
106
- // Handle objects and arrays directly
107
- if (typeof result === "object" && result !== null) {
108
- console.log("Rendering as object/array with JsonCollapsible");
109
- return <JsonCollapsible data={result as JsonValue} />;
110
  }
111
-
112
- // Handle string that might be JSON
113
- if (typeof result === "string") {
114
- const trimmed = result.trim();
115
- if (isJsonString(trimmed)) {
116
- console.log("Rendering string as JSON with JsonCollapsible");
117
- return <JsonCollapsible data={trimmed} isString={true} />;
118
- }
119
- }
120
-
121
- // Fallback to plain text display
122
- console.log("Rendering as plain text fallback");
123
- return (
124
- <pre className="text-sm text-gray-300 whitespace-pre-wrap overflow-auto">
125
- {String(result)}
126
- </pre>
127
- );
128
- };
129
-
130
- return (
131
- <div
132
- className={
133
- error
134
- ? "bg-red-900 border border-red-600 rounded p-3"
135
- : "bg-gray-700 border border-gray-600 rounded p-3"
136
- }
137
- >
138
- {error ? <p className="text-red-300 text-sm">Error: {error}</p> : null}
139
- <div className="mt-2">{renderContent()}</div>
140
- </div>
141
- );
142
- };
143
 
144
  export default ResultBlock;
 
1
  import type React from "react";
 
2
 
3
+ interface ResultBlockProps {
4
+ error?: string;
5
+ result?: unknown;
6
+ }
7
 
8
+ const ResultBlock: React.FC<ResultBlockProps> = ({
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  error,
10
  result,
11
+ }) => (
12
+ <div
13
+ className={
14
+ error
15
+ ? "bg-red-900 border border-red-600 rounded p-3"
16
+ : "bg-gray-700 border border-gray-600 rounded p-3"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  }
18
+ >
19
+ {error ? <p className="text-red-300 text-sm">Error: {error}</p> : null}
20
+ <pre className="text-sm text-gray-300 whitespace-pre-wrap overflow-auto mt-2">
21
+ {result !== undefined && result !== null
22
+ ? (typeof result === "object" ? JSON.stringify(result, null, 2) : String(result))
23
+ : "No result"}
24
+ </pre>
25
+ </div>
26
+ );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
  export default ResultBlock;
src/components/ToolResultRenderer.tsx CHANGED
@@ -1,12 +1,13 @@
1
  import React from "react";
2
  import ResultBlock from "./ResultBlock";
3
- import type { MCPToolResult } from "../types/mcp";
4
 
5
- const ToolResultRenderer: React.FC<{
6
- result: MCPToolResult;
7
  rendererCode?: string;
8
  input?: unknown;
9
- }> = ({ result, rendererCode, input }) => {
 
 
10
  if (!rendererCode) {
11
  return <ResultBlock result={result} />;
12
  }
@@ -26,34 +27,10 @@ const ToolResultRenderer: React.FC<{
26
  const { createElement: h, Fragment } = React;
27
  const JSXComponent = ${componentCode};
28
  return JSXComponent(input, output);
29
- `
30
  );
31
 
32
  const element = componentFunction(React, input || {}, result);
33
-
34
- // For object results, also show the formatted JSON below the custom renderer
35
- if (typeof result === "object" && result !== null) {
36
- return (
37
- <div>
38
- {element}
39
- <div className="mt-3 space-y-3">
40
- <div>
41
- <h4 className="text-sm font-medium text-gray-300 mb-2">
42
- Request
43
- </h4>
44
- <ResultBlock result={input || {}} />
45
- </div>
46
- <div>
47
- <h4 className="text-sm font-medium text-gray-300 mb-2">
48
- Response
49
- </h4>
50
- <ResultBlock result={result} />
51
- </div>
52
- </div>
53
- </div>
54
- );
55
- }
56
-
57
  return element;
58
  } catch (error) {
59
  return (
 
1
  import React from "react";
2
  import ResultBlock from "./ResultBlock";
 
3
 
4
+ interface ToolResultRendererProps {
5
+ result: unknown;
6
  rendererCode?: string;
7
  input?: unknown;
8
+ }
9
+
10
+ const ToolResultRenderer: React.FC<ToolResultRendererProps> = ({ result, rendererCode, input }) => {
11
  if (!rendererCode) {
12
  return <ResultBlock result={result} />;
13
  }
 
27
  const { createElement: h, Fragment } = React;
28
  const JSXComponent = ${componentCode};
29
  return JSXComponent(input, output);
30
+ `,
31
  );
32
 
33
  const element = componentFunction(React, input || {}, result);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  return element;
35
  } catch (error) {
36
  return (
src/constants/models.ts CHANGED
@@ -2,9 +2,4 @@ export const MODEL_OPTIONS = [
2
  { id: "350M", label: "LFM2-350M", size: "350M parameters (312 MB)" },
3
  { id: "700M", label: "LFM2-700M", size: "700M parameters (579 MB)" },
4
  { id: "1.2B", label: "LFM2-1.2B", size: "1.2B parameters (868 MB)" },
5
- {
6
- id: "gemma-270m",
7
- label: "Gemma-3-270M",
8
- size: "270M parameters",
9
- },
10
  ];
 
2
  { id: "350M", label: "LFM2-350M", size: "350M parameters (312 MB)" },
3
  { id: "700M", label: "LFM2-700M", size: "700M parameters (579 MB)" },
4
  { id: "1.2B", label: "LFM2-1.2B", size: "1.2B parameters (868 MB)" },
 
 
 
 
 
5
  ];
src/hooks/useLLM.ts CHANGED
@@ -13,11 +13,11 @@ interface LLMState {
13
  }
14
 
15
  interface LLMInstance {
16
- model: unknown;
17
- tokenizer: unknown;
18
  }
19
 
20
- const moduleCache: {
21
  [modelId: string]: {
22
  instance: LLMInstance | null;
23
  loadingPromise: Promise<LLMInstance> | null;
@@ -36,20 +36,14 @@ export const useLLM = (modelId?: string) => {
36
  const loadingPromiseRef = useRef<Promise<LLMInstance> | null>(null);
37
 
38
  const abortControllerRef = useRef<AbortController | null>(null);
39
- const pastKeyValuesRef = useRef<unknown>(null);
40
 
41
  const loadModel = useCallback(async () => {
42
  if (!modelId) {
43
  throw new Error("Model ID is required");
44
  }
45
 
46
- // Handle different model types
47
- let MODEL_ID: string;
48
- if (modelId === "gemma-270m") {
49
- MODEL_ID = "onnx-community/gemma-3-270m-it-ONNX";
50
- } else {
51
- MODEL_ID = `onnx-community/LFM2-${modelId}-ONNX`;
52
- }
53
 
54
  if (!moduleCache[modelId]) {
55
  moduleCache[modelId] = {
@@ -105,7 +99,7 @@ export const useLLM = (modelId?: string) => {
105
  progress.file.endsWith(".onnx_data")
106
  ) {
107
  const percentage = Math.round(
108
- (progress.loaded / progress.total) * 100
109
  );
110
  setState((prev) => ({ ...prev, progress: percentage }));
111
  }
@@ -156,7 +150,7 @@ export const useLLM = (modelId?: string) => {
156
  async (
157
  messages: Array<{ role: string; content: string }>,
158
  tools: Array<any>,
159
- onToken?: (token: string) => void
160
  ): Promise<string> => {
161
  const instance = instanceRef.current;
162
  if (!instance) {
@@ -202,7 +196,7 @@ export const useLLM = (modelId?: string) => {
202
 
203
  return response;
204
  },
205
- []
206
  );
207
 
208
  const clearPastKeyValues = useCallback(() => {
 
13
  }
14
 
15
  interface LLMInstance {
16
+ model: any;
17
+ tokenizer: any;
18
  }
19
 
20
+ let moduleCache: {
21
  [modelId: string]: {
22
  instance: LLMInstance | null;
23
  loadingPromise: Promise<LLMInstance> | null;
 
36
  const loadingPromiseRef = useRef<Promise<LLMInstance> | null>(null);
37
 
38
  const abortControllerRef = useRef<AbortController | null>(null);
39
+ const pastKeyValuesRef = useRef<any>(null);
40
 
41
  const loadModel = useCallback(async () => {
42
  if (!modelId) {
43
  throw new Error("Model ID is required");
44
  }
45
 
46
+ const MODEL_ID = `onnx-community/LFM2-${modelId}-ONNX`;
 
 
 
 
 
 
47
 
48
  if (!moduleCache[modelId]) {
49
  moduleCache[modelId] = {
 
99
  progress.file.endsWith(".onnx_data")
100
  ) {
101
  const percentage = Math.round(
102
+ (progress.loaded / progress.total) * 100,
103
  );
104
  setState((prev) => ({ ...prev, progress: percentage }));
105
  }
 
150
  async (
151
  messages: Array<{ role: string; content: string }>,
152
  tools: Array<any>,
153
+ onToken?: (token: string) => void,
154
  ): Promise<string> => {
155
  const instance = instanceRef.current;
156
  if (!instance) {
 
196
 
197
  return response;
198
  },
199
+ [],
200
  );
201
 
202
  const clearPastKeyValues = useCallback(() => {
src/hooks/useMCP.ts CHANGED
@@ -1,11 +1,7 @@
1
- import { useState, useEffect, useCallback } from "react";
2
- import { MCPClientService } from "../services/mcpClient";
3
- import type {
4
- MCPServerConfig,
5
- MCPClientState,
6
- ExtendedTool,
7
- } from "../types/mcp";
8
- import type { Tool as OriginalTool } from "../components/ToolItem";
9
 
10
  // Singleton instance
11
  let mcpClientInstance: MCPClientService | null = null;
@@ -21,7 +17,7 @@ export const useMCP = () => {
21
  const [mcpState, setMCPState] = useState<MCPClientState>({
22
  servers: {},
23
  isLoading: false,
24
- error: undefined,
25
  });
26
 
27
  const mcpClient = getMCPClient();
@@ -29,7 +25,6 @@ export const useMCP = () => {
29
  // Subscribe to MCP state changes
30
  useEffect(() => {
31
  const handleStateChange = (state: MCPClientState) => {
32
- console.log("MCP state change triggered:", Object.keys(state.servers));
33
  setMCPState(state);
34
  };
35
 
@@ -40,74 +35,41 @@ export const useMCP = () => {
40
 
41
  return () => {
42
  mcpClient.removeStateListener(handleStateChange);
43
- // Don't cleanup the service itself as it's a singleton used across the app
44
  };
45
  }, [mcpClient]);
46
 
47
  // Add a new MCP server
48
- const addServer = useCallback(
49
- async (config: MCPServerConfig): Promise<void> => {
50
- return mcpClient.addServer(config);
51
- },
52
- [mcpClient]
53
- );
54
 
55
  // Remove an MCP server
56
- const removeServer = useCallback(
57
- async (serverId: string): Promise<void> => {
58
- return mcpClient.removeServer(serverId);
59
- },
60
- [mcpClient]
61
- );
62
 
63
  // Connect to a server
64
- const connectToServer = useCallback(
65
- async (serverId: string): Promise<void> => {
66
- return mcpClient.connectToServer(serverId);
67
- },
68
- [mcpClient]
69
- );
70
 
71
  // Disconnect from a server
72
- const disconnectFromServer = useCallback(
73
- async (serverId: string): Promise<void> => {
74
- return mcpClient.disconnectFromServer(serverId);
75
- },
76
- [mcpClient]
77
- );
78
 
79
  // Test connection to a server
80
- const testConnection = useCallback(
81
- async (config: MCPServerConfig): Promise<boolean> => {
82
- return mcpClient.testConnection(config);
83
- },
84
- [mcpClient]
85
- );
86
 
87
  // Call a tool on an MCP server
88
- const callMCPTool = useCallback(
89
- async (
90
- serverId: string,
91
- toolName: string,
92
- args: Record<string, unknown>
93
- ) => {
94
- console.log("MCP tool called:", {
95
- serverId,
96
- toolName,
97
- args,
98
- timestamp: new Date().toISOString(),
99
- });
100
- console.trace("MCP tool call stack trace:");
101
- return mcpClient.callTool(serverId, toolName, args);
102
- },
103
- [mcpClient]
104
- );
105
 
106
  // Get all available MCP tools
107
  const getMCPTools = useCallback((): ExtendedTool[] => {
108
- console.log(
109
- "getMCPTools called - checking if this is being called frequently"
110
- );
111
  const mcpTools: ExtendedTool[] = [];
112
 
113
  Object.entries(mcpState.servers).forEach(([serverId, connection]) => {
@@ -120,7 +82,7 @@ export const useMCP = () => {
120
  isCollapsed: false,
121
  mcpServerId: serverId,
122
  mcpTool: mcpTool,
123
- isRemote: true,
124
  });
125
  });
126
  }
@@ -132,39 +94,32 @@ export const useMCP = () => {
132
  // Convert MCP tools to the format expected by the existing tool system
133
  const getMCPToolsAsOriginalTools = useCallback((): OriginalTool[] => {
134
  const mcpTools: OriginalTool[] = [];
135
- let globalId = 10000; // Start with high numbers to avoid conflicts
136
 
137
  Object.entries(mcpState.servers).forEach(([serverId, connection]) => {
138
  if (connection.isConnected && connection.config.enabled) {
139
  connection.tools.forEach((mcpTool) => {
140
- // Convert tool name to valid JavaScript identifier and include server name for uniqueness
141
- const serverName = connection.config.name
142
- .replace(/[-\s]/g, "_")
143
- .replace(/[^a-zA-Z0-9_]/g, "");
144
- const toolName = mcpTool.name
145
- .replace(/[-\s]/g, "_")
146
- .replace(/[^a-zA-Z0-9_]/g, "");
147
- const jsToolName = `${serverName}_${toolName}`;
148
 
149
  // Create a JavaScript function that calls the MCP tool
 
 
 
 
 
 
 
 
 
150
  const code = `/**
151
- * ${mcpTool.description || `MCP tool from ${connection.config.name}`}
152
- * Server: ${connection.config.name}
153
- * ${Object.entries(mcpTool.inputSchema.properties || {})
154
- .map(([name, prop]) => {
155
- const p = prop as { type?: string; description?: string };
156
- return `@param {${p.type || "any"}} ${name} - ${p.description || ""}`;
157
- })
158
- .join("\n * ")}
159
  * @returns {Promise<any>} Tool execution result
160
  */
161
- export async function ${jsToolName}(${Object.keys(
162
- mcpTool.inputSchema.properties || {}
163
- ).join(", ")}) {
164
  // This is an MCP tool - execution is handled by the MCP client
165
- return { mcpServerId: "${serverId}", toolName: "${
166
- mcpTool.name
167
- }", arguments: arguments };
168
  }
169
 
170
  export default (input, output) =>
@@ -173,7 +128,7 @@ export default (input, output) =>
173
  { className: "bg-blue-50 border border-blue-200 rounded-lg p-4" },
174
  React.createElement(
175
  "div",
176
- { className: "flex items-center mb-3" },
177
  React.createElement(
178
  "div",
179
  {
@@ -185,16 +140,54 @@ export default (input, output) =>
185
  React.createElement(
186
  "h3",
187
  { className: "text-blue-900 font-semibold" },
188
- "${mcpTool.name} (${connection.config.name})"
189
  ),
190
  ),
191
  React.createElement(
192
  "div",
193
- { className: "text-sm mb-2" },
194
  React.createElement(
195
  "p",
196
- { className: "text-blue-700 font-medium mb-1" },
197
- \`Server: ${connection.config.name}\`
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
  ),
199
  ),
200
  );`;
@@ -204,7 +197,7 @@ export default (input, output) =>
204
  name: jsToolName, // Use JavaScript-safe name for function calls
205
  code: code,
206
  enabled: true,
207
- isCollapsed: false,
208
  });
209
  });
210
  }
@@ -234,6 +227,6 @@ export default (input, output) =>
234
  getMCPTools,
235
  getMCPToolsAsOriginalTools,
236
  connectAll,
237
- disconnectAll,
238
  };
239
  };
 
1
+ import { useState, useEffect, useCallback } from 'react';
2
+ import { MCPClientService } from '../services/mcpClient';
3
+ import type { MCPServerConfig, MCPClientState, ExtendedTool } from '../types/mcp';
4
+ import type { Tool as OriginalTool } from '../components/ToolItem';
 
 
 
 
5
 
6
  // Singleton instance
7
  let mcpClientInstance: MCPClientService | null = null;
 
17
  const [mcpState, setMCPState] = useState<MCPClientState>({
18
  servers: {},
19
  isLoading: false,
20
+ error: undefined
21
  });
22
 
23
  const mcpClient = getMCPClient();
 
25
  // Subscribe to MCP state changes
26
  useEffect(() => {
27
  const handleStateChange = (state: MCPClientState) => {
 
28
  setMCPState(state);
29
  };
30
 
 
35
 
36
  return () => {
37
  mcpClient.removeStateListener(handleStateChange);
 
38
  };
39
  }, [mcpClient]);
40
 
41
  // Add a new MCP server
42
+ const addServer = useCallback(async (config: MCPServerConfig): Promise<void> => {
43
+ return mcpClient.addServer(config);
44
+ }, [mcpClient]);
 
 
 
45
 
46
  // Remove an MCP server
47
+ const removeServer = useCallback(async (serverId: string): Promise<void> => {
48
+ return mcpClient.removeServer(serverId);
49
+ }, [mcpClient]);
 
 
 
50
 
51
  // Connect to a server
52
+ const connectToServer = useCallback(async (serverId: string): Promise<void> => {
53
+ return mcpClient.connectToServer(serverId);
54
+ }, [mcpClient]);
 
 
 
55
 
56
  // Disconnect from a server
57
+ const disconnectFromServer = useCallback(async (serverId: string): Promise<void> => {
58
+ return mcpClient.disconnectFromServer(serverId);
59
+ }, [mcpClient]);
 
 
 
60
 
61
  // Test connection to a server
62
+ const testConnection = useCallback(async (config: MCPServerConfig): Promise<boolean> => {
63
+ return mcpClient.testConnection(config);
64
+ }, [mcpClient]);
 
 
 
65
 
66
  // Call a tool on an MCP server
67
+ const callMCPTool = useCallback(async (serverId: string, toolName: string, args: Record<string, unknown>) => {
68
+ return mcpClient.callTool(serverId, toolName, args);
69
+ }, [mcpClient]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
 
71
  // Get all available MCP tools
72
  const getMCPTools = useCallback((): ExtendedTool[] => {
 
 
 
73
  const mcpTools: ExtendedTool[] = [];
74
 
75
  Object.entries(mcpState.servers).forEach(([serverId, connection]) => {
 
82
  isCollapsed: false,
83
  mcpServerId: serverId,
84
  mcpTool: mcpTool,
85
+ isRemote: true
86
  });
87
  });
88
  }
 
94
  // Convert MCP tools to the format expected by the existing tool system
95
  const getMCPToolsAsOriginalTools = useCallback((): OriginalTool[] => {
96
  const mcpTools: OriginalTool[] = [];
97
+ let globalId = Date.now(); // Use timestamp to force tool refresh
98
 
99
  Object.entries(mcpState.servers).forEach(([serverId, connection]) => {
100
  if (connection.isConnected && connection.config.enabled) {
101
  connection.tools.forEach((mcpTool) => {
102
+ // Convert tool name to valid JavaScript identifier
103
+ const jsToolName = mcpTool.name.replace(/[-\s]/g, '_').replace(/[^a-zA-Z0-9_]/g, '');
 
 
 
 
 
 
104
 
105
  // Create a JavaScript function that calls the MCP tool
106
+ const safeDescription = (mcpTool.description || `MCP tool from ${connection.config.name}`).replace(/[`${}\\]/g, '');
107
+ const serverName = connection.config.name;
108
+ const safeParams = Object.entries(mcpTool.inputSchema.properties || {}).map(([name, prop]) => {
109
+ const p = prop as { type?: string; description?: string };
110
+ const safeType = (p.type || 'any').replace(/[`${}\\]/g, '');
111
+ const safeDesc = (p.description || '').replace(/[`${}\\]/g, '');
112
+ return `@param {${safeType}} ${name} - ${safeDesc}`;
113
+ }).join('\n * ');
114
+
115
  const code = `/**
116
+ * ${safeDescription}
117
+ * ${safeParams}
 
 
 
 
 
 
118
  * @returns {Promise<any>} Tool execution result
119
  */
120
+ export async function ${jsToolName}(${Object.keys(mcpTool.inputSchema.properties || {}).join(', ')}) {
 
 
121
  // This is an MCP tool - execution is handled by the MCP client
122
+ return { mcpServerId: "${serverId}", toolName: ${JSON.stringify(mcpTool.name)}, arguments: arguments };
 
 
123
  }
124
 
125
  export default (input, output) =>
 
128
  { className: "bg-blue-50 border border-blue-200 rounded-lg p-4" },
129
  React.createElement(
130
  "div",
131
+ { className: "flex items-center mb-2" },
132
  React.createElement(
133
  "div",
134
  {
 
140
  React.createElement(
141
  "h3",
142
  { className: "text-blue-900 font-semibold" },
143
+ "${mcpTool.name} (MCP)"
144
  ),
145
  ),
146
  React.createElement(
147
  "div",
148
+ { className: "text-sm space-y-1" },
149
  React.createElement(
150
  "p",
151
+ { className: "text-blue-700 font-medium" },
152
+ "Server: " + ${JSON.stringify(serverName)}
153
+ ),
154
+ React.createElement(
155
+ "p",
156
+ { className: "text-blue-700 font-medium" },
157
+ "Input: " + JSON.stringify(input)
158
+ ),
159
+ React.createElement(
160
+ "div",
161
+ { className: "mt-3" },
162
+ React.createElement(
163
+ "h4",
164
+ { className: "text-blue-800 font-medium mb-2" },
165
+ "Result:"
166
+ ),
167
+ React.createElement(
168
+ "pre",
169
+ {
170
+ className: "text-gray-800 text-xs bg-gray-50 p-3 rounded border overflow-x-auto max-w-full",
171
+ style: { whiteSpace: "pre-wrap", wordBreak: "break-word" }
172
+ },
173
+ (() => {
174
+ // Try to parse and format JSON content from text fields
175
+ if (output && output.content && Array.isArray(output.content)) {
176
+ const textContent = output.content.find(item => item.type === 'text' && item.text);
177
+ if (textContent && textContent.text) {
178
+ try {
179
+ const parsed = JSON.parse(textContent.text);
180
+ return JSON.stringify(parsed, null, 2);
181
+ } catch {
182
+ // If not JSON, return the original text
183
+ return textContent.text;
184
+ }
185
+ }
186
+ }
187
+ // Fallback to original output
188
+ return JSON.stringify(output, null, 2);
189
+ })()
190
+ )
191
  ),
192
  ),
193
  );`;
 
197
  name: jsToolName, // Use JavaScript-safe name for function calls
198
  code: code,
199
  enabled: true,
200
+ isCollapsed: false
201
  });
202
  });
203
  }
 
227
  getMCPTools,
228
  getMCPToolsAsOriginalTools,
229
  connectAll,
230
+ disconnectAll
231
  };
232
  };
src/main.tsx CHANGED
@@ -1,10 +1,24 @@
1
  import { StrictMode } from "react";
2
  import { createRoot } from "react-dom/client";
 
3
  import "./index.css";
4
  import App from "./App.tsx";
 
5
 
6
  createRoot(document.getElementById("root")!).render(
7
  <StrictMode>
8
- <App />
9
- </StrictMode>,
 
 
 
 
 
 
 
 
 
 
 
 
10
  );
 
1
  import { StrictMode } from "react";
2
  import { createRoot } from "react-dom/client";
3
+ import { BrowserRouter, Routes, Route } from "react-router-dom";
4
  import "./index.css";
5
  import App from "./App.tsx";
6
+ import OAuthCallback from "./components/OAuthCallback";
7
 
8
  createRoot(document.getElementById("root")!).render(
9
  <StrictMode>
10
+ <BrowserRouter>
11
+ <Routes>
12
+ <Route
13
+ path="/oauth/callback"
14
+ element={
15
+ <OAuthCallback
16
+ serverUrl={localStorage.getItem("oauth_mcp_server_url") || ""}
17
+ />
18
+ }
19
+ />
20
+ <Route path="/*" element={<App />} />
21
+ </Routes>
22
+ </BrowserRouter>
23
+ </StrictMode>
24
  );
src/services/mcpClient.ts CHANGED
@@ -10,76 +10,16 @@ import type {
10
  MCPClientState,
11
  MCPToolResult,
12
  } from "../types/mcp.js";
 
13
 
14
  export class MCPClientService {
15
  private clients: Map<string, Client> = new Map();
16
  private connections: Map<string, MCPServerConnection> = new Map();
17
  private listeners: Array<(state: MCPClientState) => void> = [];
18
- private healthCheckInterval: NodeJS.Timeout | null = null;
19
 
20
  constructor() {
21
  // Load saved server configurations from localStorage
22
  this.loadServerConfigs();
23
-
24
- // Start health check every 5 minutes
25
- this.startHealthCheck();
26
- }
27
-
28
- // Start periodic health check (every 5 minutes)
29
- private startHealthCheck() {
30
- // Clear any existing interval
31
- if (this.healthCheckInterval) {
32
- clearInterval(this.healthCheckInterval);
33
- }
34
-
35
- // Set up new interval for 5 minutes (300,000 ms)
36
- this.healthCheckInterval = setInterval(() => {
37
- this.performHealthCheck();
38
- }, 5 * 60 * 1000);
39
- }
40
-
41
- // Perform health check on all connected servers
42
- private async performHealthCheck() {
43
- console.log("Performing MCP health check...");
44
-
45
- for (const [serverId, connection] of this.connections) {
46
- if (connection.config.enabled && connection.isConnected) {
47
- try {
48
- const client = this.clients.get(serverId);
49
- if (client) {
50
- // Try to list tools as a health check
51
- await client.listTools();
52
- console.log(`Health check passed for ${serverId}`);
53
- }
54
- } catch (error) {
55
- console.warn(`Health check failed for ${serverId}:`, error);
56
- // Mark as disconnected and try to reconnect
57
- connection.isConnected = false;
58
- connection.lastError = "Health check failed";
59
-
60
- // Try to reconnect
61
- try {
62
- await this.connectToServer(serverId);
63
- console.log(`Reconnected to ${serverId}`);
64
- } catch (reconnectError) {
65
- console.error(
66
- `Failed to reconnect to ${serverId}:`,
67
- reconnectError
68
- );
69
- }
70
- }
71
- }
72
- }
73
-
74
- this.notifyStateChange();
75
- }
76
-
77
- // Clean up health check on destruction
78
- public cleanup() {
79
- if (this.healthCheckInterval) {
80
- clearInterval(this.healthCheckInterval);
81
- this.healthCheckInterval = null;
82
- }
83
  }
84
 
85
  // Add state change listener
@@ -118,7 +58,7 @@ export class MCPClientService {
118
  // Load server configurations from localStorage
119
  private loadServerConfigs() {
120
  try {
121
- const stored = localStorage.getItem("mcp-servers");
122
  if (stored) {
123
  const configs: MCPServerConfig[] = JSON.parse(stored);
124
  configs.forEach((config) => {
@@ -133,7 +73,7 @@ export class MCPClientService {
133
  });
134
  }
135
  } catch (error) {
136
- console.error("Failed to load MCP server configs:", error);
137
  }
138
  }
139
 
@@ -143,9 +83,10 @@ export class MCPClientService {
143
  const configs = Array.from(this.connections.values()).map(
144
  (conn) => conn.config
145
  );
146
- localStorage.setItem("mcp-servers", JSON.stringify(configs));
147
  } catch (error) {
148
- console.error("Failed to save MCP server configs:", error);
 
149
  }
150
  }
151
 
@@ -197,8 +138,8 @@ export class MCPClientService {
197
  // Create client
198
  const client = new Client(
199
  {
200
- name: "LFM2-WebGPU",
201
- version: "1.0.0",
202
  },
203
  {
204
  capabilities: {
@@ -276,7 +217,6 @@ export class MCPClientService {
276
 
277
  // Set up error handling
278
  client.onerror = (error) => {
279
- console.error(`MCP Client error for ${serverId}:`, error);
280
  connection.lastError = error.message;
281
  connection.isConnected = false;
282
  this.notifyStateChange();
@@ -299,7 +239,6 @@ export class MCPClientService {
299
 
300
  this.notifyStateChange();
301
  } catch (error) {
302
- console.error(`Failed to connect to MCP server ${serverId}:`, error);
303
  connection.isConnected = false;
304
  connection.lastError =
305
  error instanceof Error ? error.message : "Connection failed";
@@ -317,7 +256,7 @@ export class MCPClientService {
317
  try {
318
  await client.close();
319
  } catch (error) {
320
- console.error(`Error disconnecting from ${serverId}:`, error);
321
  }
322
  this.clients.delete(serverId);
323
  }
@@ -366,8 +305,7 @@ export class MCPClientService {
366
  isError: Boolean(result.isError),
367
  };
368
  } catch (error) {
369
- console.error(`Error calling tool ${toolName} on ${serverId}:`, error);
370
- throw error;
371
  }
372
  }
373
 
@@ -376,8 +314,8 @@ export class MCPClientService {
376
  try {
377
  const client = new Client(
378
  {
379
- name: "LFM2-WebGPU-Test",
380
- version: "1.0.0",
381
  },
382
  {
383
  capabilities: {
@@ -413,8 +351,7 @@ export class MCPClientService {
413
  await client.close();
414
  return true;
415
  } catch (error) {
416
- console.error("Test connection failed:", error);
417
- return false;
418
  }
419
  }
420
 
@@ -425,8 +362,8 @@ export class MCPClientService {
425
  ([, connection]) => connection.config.enabled && !connection.isConnected
426
  )
427
  .map(([serverId]) =>
428
- this.connectToServer(serverId).catch((error) => {
429
- console.error(`Failed to connect to ${serverId}:`, error);
430
  })
431
  );
432
 
 
10
  MCPClientState,
11
  MCPToolResult,
12
  } from "../types/mcp.js";
13
+ import { MCP_CLIENT_CONFIG, STORAGE_KEYS } from "../config/constants";
14
 
15
  export class MCPClientService {
16
  private clients: Map<string, Client> = new Map();
17
  private connections: Map<string, MCPServerConnection> = new Map();
18
  private listeners: Array<(state: MCPClientState) => void> = [];
 
19
 
20
  constructor() {
21
  // Load saved server configurations from localStorage
22
  this.loadServerConfigs();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  }
24
 
25
  // Add state change listener
 
58
  // Load server configurations from localStorage
59
  private loadServerConfigs() {
60
  try {
61
+ const stored = localStorage.getItem(STORAGE_KEYS.MCP_SERVERS);
62
  if (stored) {
63
  const configs: MCPServerConfig[] = JSON.parse(stored);
64
  configs.forEach((config) => {
 
73
  });
74
  }
75
  } catch (error) {
76
+ // Silently handle missing or corrupted config
77
  }
78
  }
79
 
 
83
  const configs = Array.from(this.connections.values()).map(
84
  (conn) => conn.config
85
  );
86
+ localStorage.setItem(STORAGE_KEYS.MCP_SERVERS, JSON.stringify(configs));
87
  } catch (error) {
88
+ // Handle storage errors gracefully
89
+ throw new Error(`Failed to save server configuration: ${error instanceof Error ? error.message : 'Unknown error'}`);
90
  }
91
  }
92
 
 
138
  // Create client
139
  const client = new Client(
140
  {
141
+ name: MCP_CLIENT_CONFIG.NAME,
142
+ version: MCP_CLIENT_CONFIG.VERSION,
143
  },
144
  {
145
  capabilities: {
 
217
 
218
  // Set up error handling
219
  client.onerror = (error) => {
 
220
  connection.lastError = error.message;
221
  connection.isConnected = false;
222
  this.notifyStateChange();
 
239
 
240
  this.notifyStateChange();
241
  } catch (error) {
 
242
  connection.isConnected = false;
243
  connection.lastError =
244
  error instanceof Error ? error.message : "Connection failed";
 
256
  try {
257
  await client.close();
258
  } catch (error) {
259
+ // Handle disconnect error silently
260
  }
261
  this.clients.delete(serverId);
262
  }
 
305
  isError: Boolean(result.isError),
306
  };
307
  } catch (error) {
308
+ throw new Error(`Tool execution failed (${toolName}): ${error instanceof Error ? error.message : 'Unknown error'}`);
 
309
  }
310
  }
311
 
 
314
  try {
315
  const client = new Client(
316
  {
317
+ name: MCP_CLIENT_CONFIG.TEST_CLIENT_NAME,
318
+ version: MCP_CLIENT_CONFIG.VERSION,
319
  },
320
  {
321
  capabilities: {
 
351
  await client.close();
352
  return true;
353
  } catch (error) {
354
+ throw new Error(`Connection test failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
 
355
  }
356
  }
357
 
 
362
  ([, connection]) => connection.config.enabled && !connection.isConnected
363
  )
364
  .map(([serverId]) =>
365
+ this.connectToServer(serverId).catch(() => {
366
+ // Handle auto-connection error silently
367
  })
368
  );
369
 
src/services/oauth.ts CHANGED
@@ -1,54 +1,239 @@
1
- // Utility for OAuth authentication with MCP servers
2
- // Usage: import { authenticateWithOAuth } from "./oauth";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
 
4
- export async function authenticateWithOAuth({
5
- authUrl,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  clientId,
7
  redirectUri,
8
- scope = "openid profile",
 
9
  }: {
10
- authUrl: string;
11
  clientId: string;
12
  redirectUri: string;
13
- scope?: string;
14
- }): Promise<string> {
15
- // Open a popup for OAuth login
16
- const state = Math.random().toString(36).substring(2);
17
- const url = `${authUrl}?response_type=token&client_id=${encodeURIComponent(
18
- clientId
19
- )}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent(
20
- scope
21
- )}&state=${state}`;
22
-
23
- return new Promise((resolve, reject) => {
24
- const popup = window.open(url, "oauth-login", "width=500,height=700");
25
- if (!popup) return reject("Popup blocked");
26
-
27
- const interval = setInterval(() => {
28
- try {
29
- if (!popup || popup.closed) {
30
- clearInterval(interval);
31
- reject("Popup closed");
32
- }
33
- // Check for access token in URL
34
- const href = popup.location.href;
35
- if (href.startsWith(redirectUri)) {
36
- const hash = popup.location.hash;
37
- const params = new URLSearchParams(hash.substring(1));
38
- const token = params.get("access_token");
39
- if (token) {
40
- clearInterval(interval);
41
- popup.close();
42
- resolve(token);
43
- } else {
44
- clearInterval(interval);
45
- popup.close();
46
- reject("No access token found");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  }
48
  }
49
- } catch (e) {
50
- // Ignore cross-origin errors until redirect
51
  }
52
- }, 500);
53
- });
 
 
 
54
  }
 
1
+ import {
2
+ discoverOAuthProtectedResourceMetadata,
3
+ discoverAuthorizationServerMetadata,
4
+ startAuthorization,
5
+ exchangeAuthorization,
6
+ registerClient,
7
+ } from "@modelcontextprotocol/sdk/client/auth.js";
8
+ import { secureStorage } from "../utils/storage";
9
+ import { MCP_CLIENT_CONFIG, STORAGE_KEYS, DEFAULTS } from "../config/constants";
10
+ // Utility to fetch .well-known/modelcontextprotocol for OAuth endpoints
11
+ export async function discoverOAuthEndpoints(serverUrl: string) {
12
+ // ...existing code...
13
+ let resourceMetadata, authMetadata, authorizationServerUrl;
14
+ try {
15
+ resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl);
16
+ if (resourceMetadata?.authorization_servers?.length) {
17
+ authorizationServerUrl = resourceMetadata.authorization_servers[0];
18
+ }
19
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
20
+ } catch (e) {
21
+ // Fallback to direct metadata discovery if protected resource fails
22
+ authMetadata = await discoverAuthorizationServerMetadata(serverUrl);
23
+ authorizationServerUrl = authMetadata?.issuer || serverUrl;
24
+ }
25
 
26
+ if (!authorizationServerUrl) {
27
+ throw new Error("No authorization server found for this MCP server");
28
+ }
29
+
30
+ // Discover authorization server metadata if not already done
31
+ if (!authMetadata) {
32
+ authMetadata = await discoverAuthorizationServerMetadata(
33
+ authorizationServerUrl
34
+ );
35
+ }
36
+
37
+ if (
38
+ !authMetadata ||
39
+ !authMetadata.authorization_endpoint ||
40
+ !authMetadata.token_endpoint
41
+ ) {
42
+ throw new Error("Missing OAuth endpoints in authorization server metadata");
43
+ }
44
+
45
+ // If client_id is missing, register client dynamically
46
+ if (!authMetadata.client_id && authMetadata.registration_endpoint) {
47
+ // Determine token endpoint auth method
48
+ let tokenEndpointAuthMethod = "none";
49
+ if (
50
+ authMetadata.token_endpoint_auth_methods_supported?.includes(
51
+ "client_secret_post"
52
+ )
53
+ ) {
54
+ tokenEndpointAuthMethod = "client_secret_post";
55
+ } else if (
56
+ authMetadata.token_endpoint_auth_methods_supported?.includes(
57
+ "client_secret_basic"
58
+ )
59
+ ) {
60
+ tokenEndpointAuthMethod = "client_secret_basic";
61
+ }
62
+ const clientMetadata = {
63
+ redirect_uris: [
64
+ String(
65
+ authMetadata.redirect_uri ||
66
+ window.location.origin + "/oauth/callback"
67
+ ),
68
+ ],
69
+ client_name: MCP_CLIENT_CONFIG.NAME,
70
+ grant_types: ["authorization_code"],
71
+ response_types: ["code"],
72
+ token_endpoint_auth_method: tokenEndpointAuthMethod,
73
+ };
74
+ const clientInfo = await registerClient(authorizationServerUrl, {
75
+ metadata: authMetadata,
76
+ clientMetadata,
77
+ });
78
+ authMetadata.client_id = clientInfo.client_id;
79
+ if (clientInfo.client_secret) {
80
+ authMetadata.client_secret = clientInfo.client_secret;
81
+ }
82
+ // Persist client credentials for later use
83
+ localStorage.setItem(STORAGE_KEYS.OAUTH_CLIENT_ID, clientInfo.client_id);
84
+ if (clientInfo.client_secret) {
85
+ await secureStorage.setItem(STORAGE_KEYS.OAUTH_CLIENT_SECRET, clientInfo.client_secret);
86
+ }
87
+ }
88
+ if (!authMetadata.client_id) {
89
+ throw new Error(
90
+ "Missing client_id and registration not supported by authorization server"
91
+ );
92
+ }
93
+
94
+ // Step 3: Validate resource
95
+ const resource = resourceMetadata?.resource
96
+ ? new URL(resourceMetadata.resource)
97
+ : undefined;
98
+
99
+ // Persist endpoints, metadata, and MCP server URL for callback use
100
+ localStorage.setItem(
101
+ STORAGE_KEYS.OAUTH_AUTHORIZATION_ENDPOINT,
102
+ authMetadata.authorization_endpoint
103
+ );
104
+ localStorage.setItem(STORAGE_KEYS.OAUTH_TOKEN_ENDPOINT, authMetadata.token_endpoint);
105
+ localStorage.setItem(
106
+ STORAGE_KEYS.OAUTH_REDIRECT_URI,
107
+ (authMetadata.redirect_uri || window.location.origin + DEFAULTS.OAUTH_REDIRECT_PATH).toString()
108
+ );
109
+ localStorage.setItem(STORAGE_KEYS.OAUTH_MCP_SERVER_URL, serverUrl);
110
+ localStorage.setItem(
111
+ STORAGE_KEYS.OAUTH_AUTHORIZATION_SERVER_METADATA,
112
+ JSON.stringify(authMetadata)
113
+ );
114
+ if (resource) {
115
+ localStorage.setItem(STORAGE_KEYS.OAUTH_RESOURCE, resource.toString());
116
+ }
117
+ return {
118
+ authorizationEndpoint: authMetadata.authorization_endpoint,
119
+ tokenEndpoint: authMetadata.token_endpoint,
120
+ clientId: authMetadata.client_id,
121
+ clientSecret: authMetadata.client_secret,
122
+ scopes: authMetadata.scopes || [],
123
+ redirectUri:
124
+ authMetadata.redirect_uri || window.location.origin + "/oauth/callback",
125
+ resource,
126
+ };
127
+ }
128
+
129
+ // Start OAuth flow: redirect user to authorization endpoint
130
+ export async function startOAuthFlow({
131
+ authorizationEndpoint,
132
  clientId,
133
  redirectUri,
134
+ scopes,
135
+ resource,
136
  }: {
137
+ authorizationEndpoint: string;
138
  clientId: string;
139
  redirectUri: string;
140
+ scopes?: string[];
141
+ resource?: URL;
142
+ }) {
143
+ // Use Proof Key for Code Exchange (PKCE) and SDK to build the authorization URL
144
+ // Use persisted client_id if available
145
+ const persistedClientId = localStorage.getItem(STORAGE_KEYS.OAUTH_CLIENT_ID) || clientId;
146
+ const clientInformation = { client_id: persistedClientId };
147
+ // Retrieve metadata from localStorage if available
148
+ let metadata;
149
+ try {
150
+ const stored = localStorage.getItem(STORAGE_KEYS.OAUTH_AUTHORIZATION_SERVER_METADATA);
151
+ if (stored) metadata = JSON.parse(stored);
152
+ } catch {
153
+ console.warn("Failed to parse stored OAuth metadata, using defaults");
154
+ }
155
+ // Always pass resource from localStorage if not provided
156
+ let resourceParam = resource;
157
+ if (!resourceParam) {
158
+ const resourceStr = localStorage.getItem(STORAGE_KEYS.OAUTH_RESOURCE);
159
+ if (resourceStr) resourceParam = new URL(resourceStr);
160
+ }
161
+ const { authorizationUrl, codeVerifier } = await startAuthorization(
162
+ authorizationEndpoint,
163
+ {
164
+ metadata,
165
+ clientInformation,
166
+ redirectUrl: redirectUri,
167
+ scope: scopes?.join(" ") || undefined,
168
+ resource: resourceParam,
169
+ }
170
+ );
171
+ // Save codeVerifier in localStorage for later token exchange
172
+ localStorage.setItem(STORAGE_KEYS.OAUTH_CODE_VERIFIER, codeVerifier);
173
+ window.location.href = authorizationUrl.toString();
174
+ }
175
+
176
+ // Exchange code for token using MCP SDK
177
+ export async function exchangeCodeForToken({
178
+ code,
179
+ redirectUri,
180
+ }: {
181
+ serverUrl?: string;
182
+ code: string;
183
+ redirectUri: string;
184
+ }) {
185
+ // Use only persisted credentials and endpoints for token exchange
186
+ const tokenEndpoint = localStorage.getItem(STORAGE_KEYS.OAUTH_TOKEN_ENDPOINT);
187
+ const redirectUriPersisted = localStorage.getItem(STORAGE_KEYS.OAUTH_REDIRECT_URI);
188
+ const resourceStr = localStorage.getItem(STORAGE_KEYS.OAUTH_RESOURCE);
189
+ const persistedClientId = localStorage.getItem(STORAGE_KEYS.OAUTH_CLIENT_ID);
190
+ const persistedClientSecret = await secureStorage.getItem(STORAGE_KEYS.OAUTH_CLIENT_SECRET);
191
+ const codeVerifier = localStorage.getItem(STORAGE_KEYS.OAUTH_CODE_VERIFIER);
192
+ if (!persistedClientId || !tokenEndpoint || !codeVerifier)
193
+ throw new Error(
194
+ "Missing OAuth client credentials or endpoints for token exchange"
195
+ );
196
+ const clientInformation: { client_id: string; client_secret?: string } = { client_id: persistedClientId };
197
+ if (persistedClientSecret) {
198
+ clientInformation.client_secret = persistedClientSecret;
199
+ }
200
+ // Retrieve metadata from localStorage if available
201
+ let metadata;
202
+ try {
203
+ const stored = localStorage.getItem(STORAGE_KEYS.OAUTH_AUTHORIZATION_SERVER_METADATA);
204
+ if (stored) metadata = JSON.parse(stored);
205
+ } catch {
206
+ console.warn("Failed to parse stored OAuth metadata, using defaults");
207
+ }
208
+ // Use SDK to exchange code for tokens
209
+ const tokens = await exchangeAuthorization(tokenEndpoint, {
210
+ metadata,
211
+ clientInformation,
212
+ authorizationCode: code,
213
+ codeVerifier,
214
+ redirectUri: redirectUriPersisted || redirectUri,
215
+ resource: resourceStr ? new URL(resourceStr) : undefined,
216
+ });
217
+ // Persist access token in localStorage and sync to mcp-servers
218
+ if (tokens && tokens.access_token) {
219
+ await secureStorage.setItem(STORAGE_KEYS.OAUTH_ACCESS_TOKEN, tokens.access_token);
220
+ try {
221
+ const serversStr = localStorage.getItem(STORAGE_KEYS.MCP_SERVERS);
222
+ if (serversStr) {
223
+ const servers = JSON.parse(serversStr);
224
+ for (const server of servers) {
225
+ if (
226
+ server.auth &&
227
+ (server.auth.type === "bearer" || server.auth.type === "oauth")
228
+ ) {
229
+ server.auth.token = tokens.access_token;
230
  }
231
  }
232
+ localStorage.setItem(STORAGE_KEYS.MCP_SERVERS, JSON.stringify(servers));
 
233
  }
234
+ } catch (err) {
235
+ console.warn("Failed to sync token to mcp-servers:", err);
236
+ }
237
+ }
238
+ return tokens;
239
  }