package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json CHANGED
@@ -11,12 +11,14 @@
11
  },
12
  "dependencies": {
13
  "@huggingface/transformers": "^3.7.1",
 
14
  "@monaco-editor/react": "^4.7.0",
15
  "@tailwindcss/vite": "^4.1.11",
16
  "idb": "^8.0.3",
17
  "lucide-react": "^0.535.0",
18
  "react": "^19.1.0",
19
  "react-dom": "^19.1.0",
 
20
  "tailwindcss": "^4.1.11"
21
  },
22
  "devDependencies": {
 
11
  },
12
  "dependencies": {
13
  "@huggingface/transformers": "^3.7.1",
14
+ "@modelcontextprotocol/sdk": "^1.17.3",
15
  "@monaco-editor/react": "^4.7.0",
16
  "@tailwindcss/vite": "^4.1.11",
17
  "idb": "^8.0.3",
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
@@ -6,8 +6,18 @@ import React, {
6
  useMemo,
7
  } from "react";
8
  import { openDB, type IDBPDatabase } from "idb";
9
- import { Play, Plus, Zap, RotateCcw, Settings, X } from "lucide-react";
 
 
 
 
 
 
 
 
 
10
  import { useLLM } from "./hooks/useLLM";
 
11
 
12
  import type { Tool } from "./components/ToolItem";
13
 
@@ -31,14 +41,15 @@ import ToolCallIndicator from "./components/ToolCallIndicator";
31
  import ToolItem from "./components/ToolItem";
32
  import ResultBlock from "./components/ResultBlock";
33
  import ExamplePrompts from "./components/ExamplePrompts";
 
34
 
35
  import { LoadingScreen } from "./components/LoadingScreen";
36
 
37
  interface RenderInfo {
38
  call: string;
39
- result?: any;
40
  renderer?: string;
41
- input?: Record<string, any>;
42
  error?: string;
43
  }
44
 
@@ -71,7 +82,7 @@ async function getDB(): Promise<IDBPDatabase> {
71
 
72
  const App: React.FC = () => {
73
  const [systemPrompt, setSystemPrompt] = useState<string>(
74
- DEFAULT_SYSTEM_PROMPT,
75
  );
76
  const [isSystemPromptModalOpen, setIsSystemPromptModalOpen] =
77
  useState<boolean>(false);
@@ -82,10 +93,12 @@ const App: React.FC = () => {
82
  const [isGenerating, setIsGenerating] = useState<boolean>(false);
83
  const isMobile = useMemo(isMobileOrTablet, []);
84
  const [selectedModelId, setSelectedModelId] = useState<string>(
85
- isMobile ? "350M" : "1.2B",
86
  );
87
  const [isModelDropdownOpen, setIsModelDropdownOpen] =
88
  useState<boolean>(false);
 
 
89
  const chatContainerRef = useRef<HTMLDivElement>(null);
90
  const debounceTimers = useRef<Record<number, NodeJS.Timeout>>({});
91
  const toolsContainerRef = useRef<HTMLDivElement>(null);
@@ -100,6 +113,13 @@ const App: React.FC = () => {
100
  clearPastKeyValues,
101
  } = useLLM(selectedModelId);
102
 
 
 
 
 
 
 
 
103
  const loadTools = useCallback(async (): Promise<void> => {
104
  const db = await getDB();
105
  const allTools: Tool[] = await db.getAll(STORE_NAME);
@@ -111,7 +131,7 @@ const App: React.FC = () => {
111
  code,
112
  enabled: true,
113
  isCollapsed: false,
114
- }),
115
  );
116
  const tx = db.transaction(STORE_NAME, "readwrite");
117
  await Promise.all(defaultTools.map((tool) => tx.store.put(tool)));
@@ -120,11 +140,19 @@ const App: React.FC = () => {
120
  } else {
121
  setTools(allTools.map((t) => ({ ...t, isCollapsed: false })));
122
  }
123
- }, []);
 
 
 
 
124
 
125
  useEffect(() => {
126
  loadTools();
127
- }, [loadTools]);
 
 
 
 
128
 
129
  useEffect(() => {
130
  if (chatContainerRef.current) {
@@ -202,16 +230,16 @@ const App: React.FC = () => {
202
  const toggleToolCollapsed = (id: number): void => {
203
  setTools(
204
  tools.map((tool) =>
205
- tool.id === id ? { ...tool, isCollapsed: !tool.isCollapsed } : tool,
206
- ),
207
  );
208
  };
209
 
210
  const expandTool = (id: number): void => {
211
  setTools(
212
  tools.map((tool) =>
213
- tool.id === id ? { ...tool, isCollapsed: false } : tool,
214
- ),
215
  );
216
  };
217
 
@@ -238,21 +266,63 @@ const App: React.FC = () => {
238
  const toolToUse = tools.find((t) => t.name === name && t.enabled);
239
  if (!toolToUse) throw new Error(`Tool '${name}' not found or is disabled.`);
240
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
241
  const { functionCode } = extractFunctionAndRenderer(toolToUse.code);
242
  const schema = generateSchemaFromCode(functionCode);
243
  const paramNames = Object.keys(schema.parameters.properties);
244
 
245
- const finalArgs: any[] = [];
246
  const requiredParams = schema.parameters.required || [];
247
 
248
  for (let i = 0; i < paramNames.length; ++i) {
249
  const paramName = paramNames[i];
250
  if (i < positionalArgs.length) {
251
  finalArgs.push(positionalArgs[i]);
252
- } else if (keywordArgs.hasOwnProperty(paramName)) {
253
  finalArgs.push(keywordArgs[paramName]);
254
  } else if (
255
- schema.parameters.properties[paramName].hasOwnProperty("default")
 
 
 
256
  ) {
257
  finalArgs.push(schema.parameters.properties[paramName].default);
258
  } else if (!requiredParams.includes(paramName)) {
@@ -265,12 +335,12 @@ const App: React.FC = () => {
265
  const bodyMatch = functionCode.match(/function[^{]+\{([\s\S]*)\}/);
266
  if (!bodyMatch) {
267
  throw new Error(
268
- "Could not parse function body. Ensure it's a standard `function` declaration.",
269
  );
270
  }
271
  const body = bodyMatch[1];
272
  const AsyncFunction = Object.getPrototypeOf(
273
- async function () {},
274
  ).constructor;
275
  const func = new AsyncFunction(...paramNames, body);
276
  const result = await func(...finalArgs);
@@ -278,7 +348,7 @@ const App: React.FC = () => {
278
  };
279
 
280
  const executeToolCalls = async (
281
- toolCallContent: string,
282
  ): Promise<RenderInfo[]> => {
283
  const toolCalls = extractPythonicCalls(toolCallContent);
284
  if (toolCalls.length === 0)
@@ -303,16 +373,16 @@ const App: React.FC = () => {
303
  parsedResult = result;
304
  }
305
 
306
- let namedParams: Record<string, any> = Object.create(null);
307
  if (parsedCall && toolUsed) {
308
  const schema = generateSchemaFromCode(
309
- extractFunctionAndRenderer(toolUsed.code).functionCode,
310
  );
311
  const paramNames = Object.keys(schema.parameters.properties);
312
  namedParams = mapArgsToNamedParams(
313
  paramNames,
314
  parsedCall.positionalArgs,
315
- parsedCall.keywordArgs,
316
  );
317
  }
318
 
@@ -334,7 +404,7 @@ const App: React.FC = () => {
334
  if (!input.trim() || !isReady) return;
335
 
336
  const userMessage: Message = { role: "user", content: input };
337
- let currentMessages: Message[] = [...messages, userMessage];
338
  setMessages(currentMessages);
339
  setInput("");
340
  setIsGenerating(true);
@@ -366,7 +436,7 @@ const App: React.FC = () => {
366
  };
367
  return updated;
368
  });
369
- },
370
  );
371
 
372
  currentMessages.push({ role: "assistant", content: response });
@@ -425,7 +495,7 @@ const App: React.FC = () => {
425
  console.error("Failed to save system prompt:", error);
426
  }
427
  },
428
- [],
429
  );
430
 
431
  const loadSelectedModel = useCallback(async (): Promise<void> => {
@@ -434,7 +504,7 @@ const App: React.FC = () => {
434
  } catch (error) {
435
  console.error("Failed to load model:", error);
436
  }
437
- }, [selectedModelId, loadModel]);
438
 
439
  const loadSelectedModelId = useCallback(async (): Promise<void> => {
440
  try {
@@ -484,7 +554,7 @@ const App: React.FC = () => {
484
  console.error("Failed to save selected model ID:", error);
485
  }
486
  },
487
- [],
488
  );
489
 
490
  useEffect(() => {
@@ -535,7 +605,7 @@ const App: React.FC = () => {
535
  };
536
  return updated;
537
  });
538
- },
539
  );
540
 
541
  currentMessages.push({ role: "assistant", content: response });
@@ -587,7 +657,11 @@ const App: React.FC = () => {
587
  />
588
  ) : (
589
  <div className="flex h-screen text-white">
590
- <div className="w-1/2 flex flex-col p-4">
 
 
 
 
591
  <div className="flex items-center justify-between mb-4">
592
  <div className="flex items-center gap-3">
593
  <h1 className="text-3xl font-bold text-gray-200">
@@ -618,6 +692,28 @@ const App: React.FC = () => {
618
  >
619
  <Settings size={16} />
620
  </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
621
  </div>
622
  </div>
623
 
@@ -643,7 +739,7 @@ const App: React.FC = () => {
643
  );
644
  } else if (msg.role === "assistant") {
645
  const isToolCall = msg.content.includes(
646
- "<|tool_call_start|>",
647
  );
648
 
649
  if (isToolCall) {
@@ -652,7 +748,7 @@ const App: React.FC = () => {
652
  const hasError =
653
  isCompleted &&
654
  (nextMessage as ToolMessage).renderInfo.some(
655
- (info) => !!info.error,
656
  );
657
 
658
  return (
@@ -680,7 +776,7 @@ const App: React.FC = () => {
680
  } else if (msg.role === "tool") {
681
  const visibleToolResults = msg.renderInfo.filter(
682
  (info) =>
683
- info.error || (info.result != null && info.renderer),
684
  );
685
 
686
  if (visibleToolResults.length === 0) return null;
@@ -745,35 +841,37 @@ const App: React.FC = () => {
745
  </div>
746
  </div>
747
 
748
- <div className="w-1/2 flex flex-col p-4 border-l border-gray-700">
749
- <div className="flex justify-between items-center mb-4">
750
- <h2 className="text-2xl font-bold text-teal-400">Tools</h2>
751
- <button
752
- onClick={addTool}
753
- className="flex items-center bg-teal-600 hover:bg-teal-700 text-white font-bold py-2 px-4 rounded-lg transition-colors"
 
 
 
 
 
 
 
 
754
  >
755
- <Plus size={16} className="mr-2" /> Add Tool
756
- </button>
757
- </div>
758
- <div
759
- ref={toolsContainerRef}
760
- className="flex-grow bg-gray-800 rounded-lg p-4 overflow-y-auto space-y-3"
761
- >
762
- {tools.map((tool) => (
763
- <ToolItem
764
- key={tool.id}
765
- tool={tool}
766
- onToggleEnabled={() => toggleToolEnabled(tool.id)}
767
- onToggleCollapsed={() => toggleToolCollapsed(tool.id)}
768
- onExpand={() => expandTool(tool.id)}
769
- onDelete={() => deleteTool(tool.id)}
770
- onCodeChange={(newCode) =>
771
- handleToolCodeChange(tool.id, newCode)
772
- }
773
- />
774
- ))}
775
  </div>
776
- </div>
777
  </div>
778
  )}
779
 
@@ -819,6 +917,12 @@ const App: React.FC = () => {
819
  </div>
820
  </div>
821
  )}
 
 
 
 
 
 
822
  </div>
823
  );
824
  };
 
6
  useMemo,
7
  } from "react";
8
  import { openDB, type IDBPDatabase } from "idb";
9
+ import {
10
+ Play,
11
+ Plus,
12
+ Zap,
13
+ RotateCcw,
14
+ Settings,
15
+ X,
16
+ PanelRightClose,
17
+ PanelRightOpen,
18
+ } from "lucide-react";
19
  import { useLLM } from "./hooks/useLLM";
20
+ import { useMCP } from "./hooks/useMCP";
21
 
22
  import type { Tool } from "./components/ToolItem";
23
 
 
41
  import ToolItem from "./components/ToolItem";
42
  import ResultBlock from "./components/ResultBlock";
43
  import ExamplePrompts from "./components/ExamplePrompts";
44
+ import { MCPServerManager } from "./components/MCPServerManager";
45
 
46
  import { LoadingScreen } from "./components/LoadingScreen";
47
 
48
  interface RenderInfo {
49
  call: string;
50
+ result?: unknown;
51
  renderer?: string;
52
+ input?: Record<string, unknown>;
53
  error?: string;
54
  }
55
 
 
82
 
83
  const App: React.FC = () => {
84
  const [systemPrompt, setSystemPrompt] = useState<string>(
85
+ DEFAULT_SYSTEM_PROMPT
86
  );
87
  const [isSystemPromptModalOpen, setIsSystemPromptModalOpen] =
88
  useState<boolean>(false);
 
93
  const [isGenerating, setIsGenerating] = useState<boolean>(false);
94
  const isMobile = useMemo(isMobileOrTablet, []);
95
  const [selectedModelId, setSelectedModelId] = useState<string>(
96
+ isMobile ? "350M" : "1.2B"
97
  );
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);
 
113
  clearPastKeyValues,
114
  } = useLLM(selectedModelId);
115
 
116
+ // MCP integration
117
+ const {
118
+ getMCPToolsAsOriginalTools,
119
+ callMCPTool,
120
+ connectAll: connectAllMCPServers,
121
+ } = useMCP();
122
+
123
  const loadTools = useCallback(async (): Promise<void> => {
124
  const db = await getDB();
125
  const allTools: Tool[] = await db.getAll(STORE_NAME);
 
131
  code,
132
  enabled: true,
133
  isCollapsed: false,
134
+ })
135
  );
136
  const tx = db.transaction(STORE_NAME, "readwrite");
137
  await Promise.all(defaultTools.map((tool) => tx.store.put(tool)));
 
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(() => {
150
  loadTools();
151
+ // Connect to MCP servers on startup
152
+ connectAllMCPServers().catch((error) => {
153
+ console.error("Failed to connect to MCP servers:", error);
154
+ });
155
+ }, [loadTools, connectAllMCPServers]);
156
 
157
  useEffect(() => {
158
  if (chatContainerRef.current) {
 
230
  const toggleToolCollapsed = (id: number): void => {
231
  setTools(
232
  tools.map((tool) =>
233
+ tool.id === id ? { ...tool, isCollapsed: !tool.isCollapsed } : tool
234
+ )
235
  );
236
  };
237
 
238
  const expandTool = (id: number): void => {
239
  setTools(
240
  tools.map((tool) =>
241
+ tool.id === id ? { ...tool, isCollapsed: false } : tool
242
+ )
243
  );
244
  };
245
 
 
266
  const toolToUse = tools.find((t) => t.name === name && t.enabled);
267
  if (!toolToUse) throw new Error(`Tool '${name}' not found or is disabled.`);
268
 
269
+ // Check if this is an MCP tool
270
+ const isMCPTool = toolToUse.code?.includes("mcpServerId:");
271
+ if (isMCPTool) {
272
+ // Extract MCP server ID and tool name from the code
273
+ const mcpServerMatch = toolToUse.code?.match(/mcpServerId: "([^"]+)"/);
274
+ const mcpToolMatch = toolToUse.code?.match(/toolName: "([^"]+)"/);
275
+
276
+ if (mcpServerMatch && mcpToolMatch) {
277
+ const serverId = mcpServerMatch[1];
278
+ const toolName = mcpToolMatch[1];
279
+
280
+ // Convert positional and keyword args to a single args object
281
+ const { functionCode } = extractFunctionAndRenderer(toolToUse.code);
282
+ const schema = generateSchemaFromCode(functionCode);
283
+ const paramNames = Object.keys(schema.parameters.properties);
284
+
285
+ const args: Record<string, unknown> = {};
286
+
287
+ // Map positional args
288
+ for (
289
+ let i = 0;
290
+ i < Math.min(positionalArgs.length, paramNames.length);
291
+ i++
292
+ ) {
293
+ args[paramNames[i]] = positionalArgs[i];
294
+ }
295
+
296
+ // Map keyword args
297
+ Object.entries(keywordArgs).forEach(([key, value]) => {
298
+ args[key] = value;
299
+ });
300
+
301
+ // Call MCP tool
302
+ const result = await callMCPTool(serverId, toolName, args);
303
+ return JSON.stringify(result);
304
+ }
305
+ }
306
+
307
+ // Handle local tools as before
308
  const { functionCode } = extractFunctionAndRenderer(toolToUse.code);
309
  const schema = generateSchemaFromCode(functionCode);
310
  const paramNames = Object.keys(schema.parameters.properties);
311
 
312
+ const finalArgs: unknown[] = [];
313
  const requiredParams = schema.parameters.required || [];
314
 
315
  for (let i = 0; i < paramNames.length; ++i) {
316
  const paramName = paramNames[i];
317
  if (i < positionalArgs.length) {
318
  finalArgs.push(positionalArgs[i]);
319
+ } else if (Object.prototype.hasOwnProperty.call(keywordArgs, paramName)) {
320
  finalArgs.push(keywordArgs[paramName]);
321
  } else if (
322
+ Object.prototype.hasOwnProperty.call(
323
+ schema.parameters.properties[paramName],
324
+ "default"
325
+ )
326
  ) {
327
  finalArgs.push(schema.parameters.properties[paramName].default);
328
  } else if (!requiredParams.includes(paramName)) {
 
335
  const bodyMatch = functionCode.match(/function[^{]+\{([\s\S]*)\}/);
336
  if (!bodyMatch) {
337
  throw new Error(
338
+ "Could not parse function body. Ensure it's a standard `function` declaration."
339
  );
340
  }
341
  const body = bodyMatch[1];
342
  const AsyncFunction = Object.getPrototypeOf(
343
+ async function () {}
344
  ).constructor;
345
  const func = new AsyncFunction(...paramNames, body);
346
  const result = await func(...finalArgs);
 
348
  };
349
 
350
  const executeToolCalls = async (
351
+ toolCallContent: string
352
  ): Promise<RenderInfo[]> => {
353
  const toolCalls = extractPythonicCalls(toolCallContent);
354
  if (toolCalls.length === 0)
 
373
  parsedResult = result;
374
  }
375
 
376
+ let namedParams: Record<string, unknown> = Object.create(null);
377
  if (parsedCall && toolUsed) {
378
  const schema = generateSchemaFromCode(
379
+ extractFunctionAndRenderer(toolUsed.code).functionCode
380
  );
381
  const paramNames = Object.keys(schema.parameters.properties);
382
  namedParams = mapArgsToNamedParams(
383
  paramNames,
384
  parsedCall.positionalArgs,
385
+ parsedCall.keywordArgs
386
  );
387
  }
388
 
 
404
  if (!input.trim() || !isReady) return;
405
 
406
  const userMessage: Message = { role: "user", content: input };
407
+ const currentMessages: Message[] = [...messages, userMessage];
408
  setMessages(currentMessages);
409
  setInput("");
410
  setIsGenerating(true);
 
436
  };
437
  return updated;
438
  });
439
+ }
440
  );
441
 
442
  currentMessages.push({ role: "assistant", content: response });
 
495
  console.error("Failed to save system prompt:", error);
496
  }
497
  },
498
+ []
499
  );
500
 
501
  const loadSelectedModel = useCallback(async (): Promise<void> => {
 
504
  } catch (error) {
505
  console.error("Failed to load model:", error);
506
  }
507
+ }, [loadModel]);
508
 
509
  const loadSelectedModelId = useCallback(async (): Promise<void> => {
510
  try {
 
554
  console.error("Failed to save selected model ID:", error);
555
  }
556
  },
557
+ []
558
  );
559
 
560
  useEffect(() => {
 
605
  };
606
  return updated;
607
  });
608
+ }
609
  );
610
 
611
  currentMessages.push({ role: "assistant", content: response });
 
657
  />
658
  ) : (
659
  <div className="flex h-screen text-white">
660
+ <div
661
+ className={`flex flex-col p-4 transition-all duration-300 ${
662
+ isToolsPanelVisible ? "w-1/2" : "w-full"
663
+ }`}
664
+ >
665
  <div className="flex items-center justify-between mb-4">
666
  <div className="flex items-center gap-3">
667
  <h1 className="text-3xl font-bold text-gray-200">
 
692
  >
693
  <Settings size={16} />
694
  </button>
695
+ <button
696
+ onClick={() => setIsMCPManagerOpen(true)}
697
+ className="h-10 flex items-center px-3 py-2 rounded-lg font-bold transition-colors bg-blue-600 hover:bg-blue-700 text-sm"
698
+ title="Manage MCP Servers"
699
+ >
700
+ 🌐
701
+ </button>
702
+ <button
703
+ onClick={() => setIsToolsPanelVisible(!isToolsPanelVisible)}
704
+ className="h-10 flex items-center px-3 py-2 rounded-lg font-bold transition-colors bg-gray-600 hover:bg-gray-700 text-sm"
705
+ title={
706
+ isToolsPanelVisible
707
+ ? "Hide Tools Panel"
708
+ : "Show Tools Panel"
709
+ }
710
+ >
711
+ {isToolsPanelVisible ? (
712
+ <PanelRightClose size={16} />
713
+ ) : (
714
+ <PanelRightOpen size={16} />
715
+ )}
716
+ </button>
717
  </div>
718
  </div>
719
 
 
739
  );
740
  } else if (msg.role === "assistant") {
741
  const isToolCall = msg.content.includes(
742
+ "<|tool_call_start|>"
743
  );
744
 
745
  if (isToolCall) {
 
748
  const hasError =
749
  isCompleted &&
750
  (nextMessage as ToolMessage).renderInfo.some(
751
+ (info) => !!info.error
752
  );
753
 
754
  return (
 
776
  } else if (msg.role === "tool") {
777
  const visibleToolResults = msg.renderInfo.filter(
778
  (info) =>
779
+ info.error || (info.result != null && info.renderer)
780
  );
781
 
782
  if (visibleToolResults.length === 0) return null;
 
841
  </div>
842
  </div>
843
 
844
+ {isToolsPanelVisible && (
845
+ <div className="w-1/2 flex flex-col p-4 border-l border-gray-700 transition-all duration-300">
846
+ <div className="flex justify-between items-center mb-4">
847
+ <h2 className="text-2xl font-bold text-teal-400">Tools</h2>
848
+ <button
849
+ onClick={addTool}
850
+ className="flex items-center bg-teal-600 hover:bg-teal-700 text-white font-bold py-2 px-4 rounded-lg transition-colors"
851
+ >
852
+ <Plus size={16} className="mr-2" /> Add Tool
853
+ </button>
854
+ </div>
855
+ <div
856
+ ref={toolsContainerRef}
857
+ className="flex-grow bg-gray-800 rounded-lg p-4 overflow-y-auto space-y-3"
858
  >
859
+ {tools.map((tool) => (
860
+ <ToolItem
861
+ key={tool.id}
862
+ tool={tool}
863
+ onToggleEnabled={() => toggleToolEnabled(tool.id)}
864
+ onToggleCollapsed={() => toggleToolCollapsed(tool.id)}
865
+ onExpand={() => expandTool(tool.id)}
866
+ onDelete={() => deleteTool(tool.id)}
867
+ onCodeChange={(newCode) =>
868
+ handleToolCodeChange(tool.id, newCode)
869
+ }
870
+ />
871
+ ))}
872
+ </div>
 
 
 
 
 
 
873
  </div>
874
+ )}
875
  </div>
876
  )}
877
 
 
917
  </div>
918
  </div>
919
  )}
920
+
921
+ {/* MCP Server Manager Modal */}
922
+ <MCPServerManager
923
+ isOpen={isMCPManagerOpen}
924
+ onClose={() => setIsMCPManagerOpen(false)}
925
+ />
926
  </div>
927
  );
928
  };
src/components/MCPServerManager.tsx ADDED
@@ -0,0 +1,509 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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;
10
+ onClose: () => void;
11
+ }
12
+
13
+ export const MCPServerManager: React.FC<MCPServerManagerProps> = ({
14
+ isOpen,
15
+ onClose,
16
+ }) => {
17
+ const {
18
+ mcpState,
19
+ addServer,
20
+ removeServer,
21
+ connectToServer,
22
+ disconnectFromServer,
23
+ testConnection,
24
+ } = useMCP();
25
+ const [showAddForm, setShowAddForm] = useState(false);
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: "",
36
+ url: "",
37
+ enabled: true,
38
+ transport: "streamable-http",
39
+ auth: {
40
+ type: "bearer",
41
+ },
42
+ });
43
+
44
+ if (!isOpen) return null;
45
+
46
+ const handleAddServer = async () => {
47
+ if (!newServer.name || !newServer.url) return;
48
+
49
+ const serverConfig: MCPServerConfig = {
50
+ ...newServer,
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({
61
+ name: "",
62
+ url: "",
63
+ enabled: true,
64
+ transport: "streamable-http",
65
+ auth: {
66
+ type: "bearer",
67
+ },
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
+
79
+ const handleTestConnection = async (config: MCPServerConfig) => {
80
+ setTestingConnection(config.id);
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
+
97
+ const handleToggleConnection = async (
98
+ serverId: string,
99
+ isConnected: boolean
100
+ ) => {
101
+ try {
102
+ if (isConnected) {
103
+ await disconnectFromServer(serverId);
104
+ } else {
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
+
116
+ return (
117
+ <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
118
+ <div className="bg-gray-800 rounded-lg p-6 w-full max-w-4xl max-h-[80vh] overflow-y-auto">
119
+ <div className="flex justify-between items-center mb-6">
120
+ <h2 className="text-2xl font-bold text-white flex items-center gap-2">
121
+ <Server className="text-blue-400" />
122
+ MCP Server Manager
123
+ </h2>
124
+ <button onClick={onClose} className="text-gray-400 hover:text-white">
125
+ βœ•
126
+ </button>
127
+ </div>
128
+
129
+ {/* Add Server Button */}
130
+ <div className="mb-6">
131
+ <button
132
+ onClick={() => setShowAddForm(!showAddForm)}
133
+ className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center gap-2"
134
+ >
135
+ <Plus size={16} />
136
+ Add MCP Server
137
+ </button>
138
+ </div>
139
+
140
+ {/* Add Server Form */}
141
+ {showAddForm && (
142
+ <div className="bg-gray-700 rounded-lg p-4 mb-6">
143
+ <h3 className="text-lg font-semibold text-white mb-4">
144
+ Add New MCP Server
145
+ </h3>
146
+ <div className="space-y-4">
147
+ <div>
148
+ <label className="block text-sm font-medium text-gray-300 mb-1">
149
+ Server Name
150
+ </label>
151
+ <input
152
+ type="text"
153
+ value={newServer.name}
154
+ onChange={(e) =>
155
+ setNewServer({ ...newServer, name: e.target.value })
156
+ }
157
+ className="w-full bg-gray-600 text-white rounded px-3 py-2"
158
+ placeholder="My MCP Server"
159
+ />
160
+ </div>
161
+
162
+ <div>
163
+ <label className="block text-sm font-medium text-gray-300 mb-1">
164
+ Server URL
165
+ </label>
166
+ <input
167
+ type="url"
168
+ value={newServer.url}
169
+ onChange={(e) =>
170
+ setNewServer({ ...newServer, url: e.target.value })
171
+ }
172
+ className="w-full bg-gray-600 text-white rounded px-3 py-2"
173
+ placeholder="http://localhost:3000/mcp"
174
+ />
175
+ </div>
176
+
177
+ <div>
178
+ <label className="block text-sm font-medium text-gray-300 mb-1">
179
+ Transport
180
+ </label>
181
+ <select
182
+ value={newServer.transport}
183
+ onChange={(e) =>
184
+ setNewServer({
185
+ ...newServer,
186
+ transport: e.target.value as MCPServerConfig["transport"],
187
+ })
188
+ }
189
+ className="w-full bg-gray-600 text-white rounded px-3 py-2"
190
+ >
191
+ <option value="streamable-http">Streamable HTTP</option>
192
+ <option value="sse">Server-Sent Events</option>
193
+ <option value="websocket">WebSocket</option>
194
+ </select>
195
+ </div>
196
+
197
+ <div>
198
+ <label className="block text-sm font-medium text-gray-300 mb-1">
199
+ Authentication
200
+ </label>
201
+ <select
202
+ value={newServer.auth?.type || "none"}
203
+ onChange={(e) => {
204
+ const authType = e.target.value;
205
+ if (authType === "none") {
206
+ setNewServer({ ...newServer, auth: undefined });
207
+ } else {
208
+ setNewServer({
209
+ ...newServer,
210
+ auth: {
211
+ type: authType as "bearer" | "basic" | "oauth",
212
+ ...(authType === "bearer" ? { token: "" } : {}),
213
+ ...(authType === "basic"
214
+ ? { username: "", password: "" }
215
+ : {}),
216
+ ...(authType === "oauth" ? { token: "" } : {}),
217
+ },
218
+ });
219
+ }
220
+ }}
221
+ className="w-full bg-gray-600 text-white rounded px-3 py-2"
222
+ >
223
+ <option value="none">No Authentication</option>
224
+ <option value="bearer">Bearer Token</option>
225
+ <option value="basic">Basic Auth</option>
226
+ <option value="oauth">OAuth Token</option>
227
+ </select>
228
+ </div>
229
+
230
+ {/* Auth-specific fields */}
231
+ {newServer.auth?.type === "bearer" && (
232
+ <div>
233
+ <label className="block text-sm font-medium text-gray-300 mb-1">
234
+ Bearer Token
235
+ </label>
236
+ <input
237
+ type="password"
238
+ value={newServer.auth.token || ""}
239
+ onChange={(e) =>
240
+ setNewServer({
241
+ ...newServer,
242
+ auth: { ...newServer.auth!, token: e.target.value },
243
+ })
244
+ }
245
+ className="w-full bg-gray-600 text-white rounded px-3 py-2"
246
+ placeholder="your-bearer-token"
247
+ />
248
+ </div>
249
+ )}
250
+
251
+ {newServer.auth?.type === "basic" && (
252
+ <>
253
+ <div>
254
+ <label className="block text-sm font-medium text-gray-300 mb-1">
255
+ Username
256
+ </label>
257
+ <input
258
+ type="text"
259
+ value={newServer.auth.username || ""}
260
+ onChange={(e) =>
261
+ setNewServer({
262
+ ...newServer,
263
+ auth: {
264
+ ...newServer.auth!,
265
+ username: e.target.value,
266
+ },
267
+ })
268
+ }
269
+ className="w-full bg-gray-600 text-white rounded px-3 py-2"
270
+ placeholder="username"
271
+ />
272
+ </div>
273
+ <div>
274
+ <label className="block text-sm font-medium text-gray-300 mb-1">
275
+ Password
276
+ </label>
277
+ <input
278
+ type="password"
279
+ value={newServer.auth.password || ""}
280
+ onChange={(e) =>
281
+ setNewServer({
282
+ ...newServer,
283
+ auth: {
284
+ ...newServer.auth!,
285
+ password: e.target.value,
286
+ },
287
+ })
288
+ }
289
+ className="w-full bg-gray-600 text-white rounded px-3 py-2"
290
+ placeholder="password"
291
+ />
292
+ </div>
293
+ </>
294
+ )}
295
+
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
+
316
+ if (!endpoints.clientId || !endpoints.redirectUri) {
317
+ throw new Error("Missing required OAuth configuration (clientId or redirectUri)");
318
+ }
319
+
320
+ startOAuthFlow({
321
+ authorizationEndpoint:
322
+ endpoints.authorizationEndpoint,
323
+ clientId: endpoints.clientId as string,
324
+ redirectUri: endpoints.redirectUri as string,
325
+ scopes: (endpoints.scopes || []) as string[],
326
+ });
327
+ } catch (err) {
328
+ setNotification({
329
+ message: "OAuth discovery failed: " +
330
+ (err instanceof Error ? err.message : String(err)),
331
+ type: 'error'
332
+ });
333
+ setTimeout(() => setNotification(null), DEFAULTS.OAUTH_ERROR_TIMEOUT);
334
+ }
335
+ }}
336
+ >
337
+ Connect with OAuth
338
+ </button>
339
+ <p className="text-xs text-gray-400">
340
+ You will be redirected to authorize this app with the MCP
341
+ server.
342
+ </p>
343
+ </div>
344
+ )}
345
+
346
+ <div className="flex items-center gap-2">
347
+ <input
348
+ type="checkbox"
349
+ id="enabled"
350
+ checked={newServer.enabled}
351
+ onChange={(e) =>
352
+ setNewServer({ ...newServer, enabled: e.target.checked })
353
+ }
354
+ className="rounded"
355
+ />
356
+ <label htmlFor="enabled" className="text-sm text-gray-300">
357
+ Auto-connect when added
358
+ </label>
359
+ </div>
360
+
361
+ <div className="flex gap-2">
362
+ <button
363
+ onClick={handleAddServer}
364
+ className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded"
365
+ >
366
+ Add Server
367
+ </button>
368
+ <button
369
+ onClick={() => setShowAddForm(false)}
370
+ className="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded"
371
+ >
372
+ Cancel
373
+ </button>
374
+ </div>
375
+ </div>
376
+ </div>
377
+ )}
378
+
379
+ {/* Server List */}
380
+ <div className="space-y-4">
381
+ <h3 className="text-lg font-semibold text-white">
382
+ Configured Servers
383
+ </h3>
384
+
385
+ {Object.values(mcpState.servers).length === 0 ? (
386
+ <div className="text-gray-400 text-center py-8">
387
+ No MCP servers configured. Add one to get started!
388
+ </div>
389
+ ) : (
390
+ Object.values(mcpState.servers).map((connection) => (
391
+ <div
392
+ key={connection.config.id}
393
+ className="bg-gray-700 rounded-lg p-4"
394
+ >
395
+ <div className="flex items-center justify-between">
396
+ <div className="flex items-center gap-3">
397
+ <div
398
+ className={`w-3 h-3 rounded-full ${
399
+ connection.isConnected ? "bg-green-400" : "bg-red-400"
400
+ }`}
401
+ />
402
+ <div>
403
+ <h4 className="text-white font-medium">
404
+ {connection.config.name}
405
+ </h4>
406
+ <p className="text-gray-400 text-sm">
407
+ {connection.config.url}
408
+ </p>
409
+ <p className="text-gray-500 text-xs">
410
+ Transport: {connection.config.transport}
411
+ {connection.config.auth &&
412
+ ` β€’ Auth: ${connection.config.auth.type}`}
413
+ {connection.isConnected &&
414
+ ` β€’ ${connection.tools.length} tools available`}
415
+ </p>
416
+ </div>
417
+ </div>
418
+
419
+ <div className="flex items-center gap-2">
420
+ {/* Test Connection */}
421
+ <button
422
+ onClick={() => handleTestConnection(connection.config)}
423
+ disabled={testingConnection === connection.config.id}
424
+ className="p-2 text-yellow-400 hover:text-yellow-300 disabled:opacity-50"
425
+ title="Test Connection"
426
+ >
427
+ <TestTube size={16} />
428
+ </button>
429
+
430
+ {/* Connect/Disconnect */}
431
+ <button
432
+ onClick={() =>
433
+ handleToggleConnection(
434
+ connection.config.id,
435
+ connection.isConnected
436
+ )
437
+ }
438
+ className={`p-2 ${
439
+ connection.isConnected
440
+ ? "text-green-400 hover:text-green-300"
441
+ : "text-gray-400 hover:text-gray-300"
442
+ }`}
443
+ title={connection.isConnected ? "Disconnect" : "Connect"}
444
+ >
445
+ {connection.isConnected ? (
446
+ <Wifi size={16} />
447
+ ) : (
448
+ <WifiOff size={16} />
449
+ )}
450
+ </button>
451
+
452
+ {/* Remove Server */}
453
+ <button
454
+ onClick={() => removeServer(connection.config.id)}
455
+ className="p-2 text-red-400 hover:text-red-300"
456
+ title="Remove Server"
457
+ >
458
+ <Trash2 size={16} />
459
+ </button>
460
+ </div>
461
+ </div>
462
+
463
+ {connection.lastError && (
464
+ <div className="mt-2 text-red-400 text-sm">
465
+ Error: {connection.lastError}
466
+ </div>
467
+ )}
468
+
469
+ {connection.isConnected && connection.tools.length > 0 && (
470
+ <div className="mt-3">
471
+ <details className="text-sm">
472
+ <summary className="text-gray-300 cursor-pointer">
473
+ Available Tools ({connection.tools.length})
474
+ </summary>
475
+ <div className="mt-2 space-y-1">
476
+ {connection.tools.map((tool) => (
477
+ <div key={tool.name} className="text-gray-400 pl-4">
478
+ β€’ {tool.name} -{" "}
479
+ {tool.description || "No description"}
480
+ </div>
481
+ ))}
482
+ </div>
483
+ </details>
484
+ </div>
485
+ )}
486
+ </div>
487
+ ))
488
+ )}
489
+ </div>
490
+
491
+ {mcpState.error && (
492
+ <div className="mt-4 p-4 bg-red-900 border border-red-700 rounded-lg text-red-200">
493
+ <strong>Error:</strong> {mcpState.error}
494
+ </div>
495
+ )}
496
+
497
+ {notification && (
498
+ <div className={`mt-4 p-4 border rounded-lg ${
499
+ notification.type === 'success'
500
+ ? 'bg-green-900 border-green-700 text-green-200'
501
+ : 'bg-red-900 border-red-700 text-red-200'
502
+ }`}>
503
+ {notification.message}
504
+ </div>
505
+ )}
506
+ </div>
507
+ </div>
508
+ );
509
+ };
src/components/OAuthCallback.tsx ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState } from "react";
2
+ import { exchangeCodeForToken } from "../services/oauth";
3
+ import { secureStorage } from "../utils/storage";
4
+ import type { MCPServerConfig } from "../types/mcp";
5
+ import { STORAGE_KEYS, DEFAULTS } from "../config/constants";
6
+
7
+ interface OAuthTokens {
8
+ access_token: string;
9
+ refresh_token?: string;
10
+ expires_in?: number;
11
+ token_type?: string;
12
+ [key: string]: string | number | undefined;
13
+ }
14
+
15
+ interface OAuthCallbackProps {
16
+ serverUrl: string;
17
+ onSuccess?: (tokens: OAuthTokens) => void;
18
+ onError?: (error: Error) => void;
19
+ }
20
+
21
+ const OAuthCallback: React.FC<OAuthCallbackProps> = ({
22
+ serverUrl,
23
+ onSuccess,
24
+ onError,
25
+ }) => {
26
+ const [status, setStatus] = useState<string>("Authorizing...");
27
+
28
+ useEffect(() => {
29
+ const params = new URLSearchParams(window.location.search);
30
+ const code = params.get("code");
31
+ // Always persist MCP server URL for robustness
32
+ localStorage.setItem(STORAGE_KEYS.OAUTH_MCP_SERVER_URL, serverUrl);
33
+ if (code) {
34
+ exchangeCodeForToken({
35
+ serverUrl,
36
+ code,
37
+ redirectUri: window.location.origin + DEFAULTS.OAUTH_REDIRECT_PATH,
38
+ })
39
+ .then(async (tokens) => {
40
+ await secureStorage.setItem(STORAGE_KEYS.OAUTH_ACCESS_TOKEN, tokens.access_token);
41
+ // Add MCP server to MCPClientService for UI
42
+ const mcpServerUrl = localStorage.getItem(STORAGE_KEYS.OAUTH_MCP_SERVER_URL);
43
+ if (mcpServerUrl) {
44
+ // Use persisted name and transport from initial add
45
+ const serverName =
46
+ localStorage.getItem(STORAGE_KEYS.MCP_SERVER_NAME) || mcpServerUrl;
47
+ const serverTransport =
48
+ (localStorage.getItem(STORAGE_KEYS.MCP_SERVER_TRANSPORT) as MCPServerConfig['transport']) || DEFAULTS.MCP_TRANSPORT;
49
+ // Build config and add to mcp-servers
50
+ const serverConfig = {
51
+ id: `server_${Date.now()}`,
52
+ name: serverName,
53
+ url: mcpServerUrl,
54
+ enabled: true,
55
+ transport: serverTransport,
56
+ auth: {
57
+ type: "bearer" as const,
58
+ token: tokens.access_token,
59
+ },
60
+ };
61
+ // Load existing servers
62
+ let servers: MCPServerConfig[] = [];
63
+ try {
64
+ const stored = localStorage.getItem(STORAGE_KEYS.MCP_SERVERS);
65
+ if (stored) servers = JSON.parse(stored);
66
+ } catch {}
67
+ // Add or update
68
+ const exists = servers.some((s: MCPServerConfig) => s.url === mcpServerUrl);
69
+ if (!exists) {
70
+ servers.push(serverConfig);
71
+ localStorage.setItem(STORAGE_KEYS.MCP_SERVERS, JSON.stringify(servers));
72
+ }
73
+ // Clear temp values from localStorage for clean slate
74
+ localStorage.removeItem(STORAGE_KEYS.MCP_SERVER_NAME);
75
+ localStorage.removeItem(STORAGE_KEYS.MCP_SERVER_TRANSPORT);
76
+ localStorage.removeItem(STORAGE_KEYS.OAUTH_MCP_SERVER_URL);
77
+ }
78
+ setStatus("Authorization successful! Redirecting...");
79
+ if (onSuccess) onSuccess(tokens);
80
+ // Redirect to main app page after short delay
81
+ setTimeout(() => {
82
+ window.location.replace("/");
83
+ }, 1000);
84
+ })
85
+ .catch((err) => {
86
+ setStatus("OAuth token exchange failed: " + err.message);
87
+ if (onError) onError(err);
88
+ });
89
+ } else {
90
+ setStatus("Missing authorization code in callback URL.");
91
+ }
92
+ }, [serverUrl, onSuccess, onError]);
93
+
94
+ return <div>{status}</div>;
95
+ };
96
+
97
+ export default OAuthCallback;
src/components/ResultBlock.tsx CHANGED
@@ -1,6 +1,11 @@
1
  import type React from "react";
2
 
3
- const ResultBlock: React.FC<{ error?: string; result?: any }> = ({
 
 
 
 
 
4
  error,
5
  result,
6
  }) => (
@@ -13,7 +18,9 @@ const ResultBlock: React.FC<{ error?: string; result?: any }> = ({
13
  >
14
  {error ? <p className="text-red-300 text-sm">Error: {error}</p> : null}
15
  <pre className="text-sm text-gray-300 whitespace-pre-wrap overflow-auto mt-2">
16
- {typeof result === "object" ? JSON.stringify(result, null, 2) : result}
 
 
17
  </pre>
18
  </div>
19
  );
 
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
  }) => (
 
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
  );
src/components/ToolResultRenderer.tsx CHANGED
@@ -1,11 +1,13 @@
1
  import React from "react";
2
  import ResultBlock from "./ResultBlock";
3
 
4
- const ToolResultRenderer: React.FC<{
5
- result: any;
6
  rendererCode?: string;
7
- input?: any;
8
- }> = ({ result, rendererCode, input }) => {
 
 
9
  if (!rendererCode) {
10
  return <ResultBlock result={result} />;
11
  }
 
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
  }
src/config/constants.ts ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Application configuration constants
3
+ */
4
+
5
+ // MCP Client Configuration
6
+ export const MCP_CLIENT_CONFIG = {
7
+ NAME: "LFM2-WebGPU",
8
+ VERSION: "1.0.0",
9
+ TEST_CLIENT_NAME: "LFM2-WebGPU-Test",
10
+ } as const;
11
+
12
+ // Storage Keys
13
+ export const STORAGE_KEYS = {
14
+ MCP_SERVERS: "mcp-servers",
15
+ OAUTH_CLIENT_ID: "oauth_client_id",
16
+ OAUTH_CLIENT_SECRET: "oauth_client_secret",
17
+ OAUTH_AUTHORIZATION_ENDPOINT: "oauth_authorization_endpoint",
18
+ OAUTH_TOKEN_ENDPOINT: "oauth_token_endpoint",
19
+ OAUTH_REDIRECT_URI: "oauth_redirect_uri",
20
+ OAUTH_RESOURCE: "oauth_resource",
21
+ OAUTH_ACCESS_TOKEN: "oauth_access_token",
22
+ OAUTH_CODE_VERIFIER: "oauth_code_verifier",
23
+ OAUTH_MCP_SERVER_URL: "oauth_mcp_server_url",
24
+ OAUTH_AUTHORIZATION_SERVER_METADATA: "oauth_authorization_server_metadata",
25
+ MCP_SERVER_NAME: "mcp_server_name",
26
+ MCP_SERVER_TRANSPORT: "mcp_server_transport",
27
+ } as const;
28
+
29
+ // Default Values
30
+ export const DEFAULTS = {
31
+ MCP_TRANSPORT: "streamable-http" as const,
32
+ OAUTH_REDIRECT_PATH: "/oauth/callback",
33
+ NOTIFICATION_TIMEOUT: 3000,
34
+ OAUTH_ERROR_TIMEOUT: 5000,
35
+ } as const;
src/hooks/useMCP.ts ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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;
8
+
9
+ const getMCPClient = (): MCPClientService => {
10
+ if (!mcpClientInstance) {
11
+ mcpClientInstance = new MCPClientService();
12
+ }
13
+ return mcpClientInstance;
14
+ };
15
+
16
+ export const useMCP = () => {
17
+ const [mcpState, setMCPState] = useState<MCPClientState>({
18
+ servers: {},
19
+ isLoading: false,
20
+ error: undefined
21
+ });
22
+
23
+ const mcpClient = getMCPClient();
24
+
25
+ // Subscribe to MCP state changes
26
+ useEffect(() => {
27
+ const handleStateChange = (state: MCPClientState) => {
28
+ setMCPState(state);
29
+ };
30
+
31
+ mcpClient.addStateListener(handleStateChange);
32
+
33
+ // Get initial state
34
+ setMCPState(mcpClient.getState());
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]) => {
76
+ if (connection.isConnected && connection.config.enabled) {
77
+ connection.tools.forEach((mcpTool) => {
78
+ mcpTools.push({
79
+ id: `${serverId}:${mcpTool.name}`,
80
+ name: mcpTool.name,
81
+ enabled: true,
82
+ isCollapsed: false,
83
+ mcpServerId: serverId,
84
+ mcpTool: mcpTool,
85
+ isRemote: true
86
+ });
87
+ });
88
+ }
89
+ });
90
+
91
+ return mcpTools;
92
+ }, [mcpState.servers]);
93
+
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) =>
126
+ React.createElement(
127
+ "div",
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
+ {
135
+ className:
136
+ "w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center mr-3",
137
+ },
138
+ "🌐",
139
+ ),
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
+ );`;
194
+
195
+ mcpTools.push({
196
+ id: globalId++,
197
+ name: jsToolName, // Use JavaScript-safe name for function calls
198
+ code: code,
199
+ enabled: true,
200
+ isCollapsed: false
201
+ });
202
+ });
203
+ }
204
+ });
205
+
206
+ return mcpTools;
207
+ }, [mcpState.servers]);
208
+
209
+ // Connect to all enabled servers
210
+ const connectAll = useCallback(async (): Promise<void> => {
211
+ return mcpClient.connectAll();
212
+ }, [mcpClient]);
213
+
214
+ // Disconnect from all servers
215
+ const disconnectAll = useCallback(async (): Promise<void> => {
216
+ return mcpClient.disconnectAll();
217
+ }, [mcpClient]);
218
+
219
+ return {
220
+ mcpState,
221
+ addServer,
222
+ removeServer,
223
+ connectToServer,
224
+ disconnectFromServer,
225
+ testConnection,
226
+ callMCPTool,
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 ADDED
@@ -0,0 +1,381 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { WebSocketClientTransport } from "@modelcontextprotocol/sdk/client/websocket.js";
3
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
4
+ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
5
+ import type { Tool } from "@modelcontextprotocol/sdk/types.js";
6
+
7
+ import type {
8
+ MCPServerConfig,
9
+ MCPServerConnection,
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
26
+ addStateListener(listener: (state: MCPClientState) => void) {
27
+ this.listeners.push(listener);
28
+ }
29
+
30
+ // Remove state change listener
31
+ removeStateListener(listener: (state: MCPClientState) => void) {
32
+ const index = this.listeners.indexOf(listener);
33
+ if (index > -1) {
34
+ this.listeners.splice(index, 1);
35
+ }
36
+ }
37
+
38
+ // Notify all listeners of state changes
39
+ private notifyStateChange() {
40
+ const state = this.getState();
41
+ this.listeners.forEach((listener) => listener(state));
42
+ }
43
+
44
+ // Get current MCP client state
45
+ getState(): MCPClientState {
46
+ const servers: Record<string, MCPServerConnection> = {};
47
+ for (const [id, connection] of this.connections) {
48
+ servers[id] = connection;
49
+ }
50
+
51
+ return {
52
+ servers,
53
+ isLoading: false,
54
+ error: undefined,
55
+ };
56
+ }
57
+
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) => {
65
+ const connection: MCPServerConnection = {
66
+ config,
67
+ isConnected: false,
68
+ tools: [],
69
+ lastError: undefined,
70
+ lastConnected: undefined,
71
+ };
72
+ this.connections.set(config.id, connection);
73
+ });
74
+ }
75
+ } catch (error) {
76
+ // Silently handle missing or corrupted config
77
+ }
78
+ }
79
+
80
+ // Save server configurations to localStorage
81
+ private saveServerConfigs() {
82
+ try {
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
+
93
+ // Add a new MCP server
94
+ async addServer(config: MCPServerConfig): Promise<void> {
95
+ const connection: MCPServerConnection = {
96
+ config,
97
+ isConnected: false,
98
+ tools: [],
99
+ lastError: undefined,
100
+ lastConnected: undefined,
101
+ };
102
+
103
+ this.connections.set(config.id, connection);
104
+ this.saveServerConfigs();
105
+ this.notifyStateChange();
106
+
107
+ // Auto-connect if enabled
108
+ if (config.enabled) {
109
+ await this.connectToServer(config.id);
110
+ }
111
+ }
112
+
113
+ // Remove an MCP server
114
+ async removeServer(serverId: string): Promise<void> {
115
+ // Disconnect first if connected
116
+ await this.disconnectFromServer(serverId);
117
+
118
+ // Remove from our maps
119
+ this.connections.delete(serverId);
120
+ this.clients.delete(serverId);
121
+
122
+ this.saveServerConfigs();
123
+ this.notifyStateChange();
124
+ }
125
+
126
+ // Connect to an MCP server
127
+ async connectToServer(serverId: string): Promise<void> {
128
+ const connection = this.connections.get(serverId);
129
+ if (!connection) {
130
+ throw new Error(`Server ${serverId} not found`);
131
+ }
132
+
133
+ if (connection.isConnected) {
134
+ return; // Already connected
135
+ }
136
+
137
+ try {
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: {
146
+ tools: {},
147
+ },
148
+ }
149
+ );
150
+
151
+ // Create transport based on config
152
+ let transport;
153
+ const url = new URL(connection.config.url);
154
+
155
+ // Prepare headers for authentication
156
+ const headers: Record<string, string> = {};
157
+ if (connection.config.auth) {
158
+ switch (connection.config.auth.type) {
159
+ case "bearer":
160
+ if (connection.config.auth.token) {
161
+ headers[
162
+ "Authorization"
163
+ ] = `Bearer ${connection.config.auth.token}`;
164
+ }
165
+ break;
166
+ case "basic":
167
+ if (
168
+ connection.config.auth.username &&
169
+ connection.config.auth.password
170
+ ) {
171
+ const credentials = btoa(
172
+ `${connection.config.auth.username}:${connection.config.auth.password}`
173
+ );
174
+ headers["Authorization"] = `Basic ${credentials}`;
175
+ }
176
+ break;
177
+ case "oauth":
178
+ if (connection.config.auth.token) {
179
+ headers[
180
+ "Authorization"
181
+ ] = `Bearer ${connection.config.auth.token}`;
182
+ }
183
+ break;
184
+ }
185
+ }
186
+
187
+ switch (connection.config.transport) {
188
+ case "websocket": {
189
+ // Convert HTTP/HTTPS URLs to WS/WSS
190
+ const wsUrl = new URL(connection.config.url);
191
+ wsUrl.protocol = wsUrl.protocol === "https:" ? "wss:" : "ws:";
192
+ transport = new WebSocketClientTransport(wsUrl);
193
+ // Note: WebSocket auth headers would need to be passed differently
194
+ // For now, auth is only supported on HTTP-based transports
195
+ break;
196
+ }
197
+
198
+ case "streamable-http":
199
+ transport = new StreamableHTTPClientTransport(url, {
200
+ requestInit:
201
+ Object.keys(headers).length > 0 ? { headers } : undefined,
202
+ });
203
+ break;
204
+
205
+ case "sse":
206
+ transport = new SSEClientTransport(url, {
207
+ requestInit:
208
+ Object.keys(headers).length > 0 ? { headers } : undefined,
209
+ });
210
+ break;
211
+
212
+ default:
213
+ throw new Error(
214
+ `Unsupported transport: ${connection.config.transport}`
215
+ );
216
+ }
217
+
218
+ // Set up error handling
219
+ client.onerror = (error) => {
220
+ connection.lastError = error.message;
221
+ connection.isConnected = false;
222
+ this.notifyStateChange();
223
+ };
224
+
225
+ // Connect to the server
226
+ await client.connect(transport);
227
+
228
+ // List available tools
229
+ const toolsResult = await client.listTools();
230
+
231
+ // Update connection state
232
+ connection.isConnected = true;
233
+ connection.tools = toolsResult.tools;
234
+ connection.lastError = undefined;
235
+ connection.lastConnected = new Date();
236
+
237
+ // Store client reference
238
+ this.clients.set(serverId, client);
239
+
240
+ this.notifyStateChange();
241
+ } catch (error) {
242
+ connection.isConnected = false;
243
+ connection.lastError =
244
+ error instanceof Error ? error.message : "Connection failed";
245
+ this.notifyStateChange();
246
+ throw error;
247
+ }
248
+ }
249
+
250
+ // Disconnect from an MCP server
251
+ async disconnectFromServer(serverId: string): Promise<void> {
252
+ const client = this.clients.get(serverId);
253
+ const connection = this.connections.get(serverId);
254
+
255
+ if (client) {
256
+ try {
257
+ await client.close();
258
+ } catch (error) {
259
+ // Handle disconnect error silently
260
+ }
261
+ this.clients.delete(serverId);
262
+ }
263
+
264
+ if (connection) {
265
+ connection.isConnected = false;
266
+ connection.tools = [];
267
+ this.notifyStateChange();
268
+ }
269
+ }
270
+
271
+ // Get all tools from all connected servers
272
+ getAllTools(): Tool[] {
273
+ const allTools: Tool[] = [];
274
+
275
+ for (const connection of this.connections.values()) {
276
+ if (connection.isConnected && connection.config.enabled) {
277
+ allTools.push(...connection.tools);
278
+ }
279
+ }
280
+
281
+ return allTools;
282
+ }
283
+
284
+ // Call a tool on an MCP server
285
+ async callTool(
286
+ serverId: string,
287
+ toolName: string,
288
+ args: Record<string, unknown>
289
+ ): Promise<MCPToolResult> {
290
+ const client = this.clients.get(serverId);
291
+ const connection = this.connections.get(serverId);
292
+
293
+ if (!client || !connection?.isConnected) {
294
+ throw new Error(`Not connected to server ${serverId}`);
295
+ }
296
+
297
+ try {
298
+ const result = await client.callTool({
299
+ name: toolName,
300
+ arguments: args,
301
+ });
302
+
303
+ return {
304
+ content: Array.isArray(result.content) ? result.content : [],
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
+
312
+ // Test connection to a server without saving it
313
+ async testConnection(config: MCPServerConfig): Promise<boolean> {
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: {
322
+ tools: {},
323
+ },
324
+ }
325
+ );
326
+
327
+ let transport;
328
+ const url = new URL(config.url);
329
+
330
+ switch (config.transport) {
331
+ case "websocket": {
332
+ const wsUrl = new URL(config.url);
333
+ wsUrl.protocol = wsUrl.protocol === "https:" ? "wss:" : "ws:";
334
+ transport = new WebSocketClientTransport(wsUrl);
335
+ break;
336
+ }
337
+
338
+ case "streamable-http":
339
+ transport = new StreamableHTTPClientTransport(url);
340
+ break;
341
+
342
+ case "sse":
343
+ transport = new SSEClientTransport(url);
344
+ break;
345
+
346
+ default:
347
+ throw new Error(`Unsupported transport: ${config.transport}`);
348
+ }
349
+
350
+ await client.connect(transport);
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
+
358
+ // Connect to all enabled servers
359
+ async connectAll(): Promise<void> {
360
+ const promises = Array.from(this.connections.entries())
361
+ .filter(
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
+
370
+ await Promise.all(promises);
371
+ }
372
+
373
+ // Disconnect from all servers
374
+ async disconnectAll(): Promise<void> {
375
+ const promises = Array.from(this.connections.keys()).map((serverId) =>
376
+ this.disconnectFromServer(serverId)
377
+ );
378
+
379
+ await Promise.all(promises);
380
+ }
381
+ }
src/services/oauth.ts ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ }
src/types/mcp.ts ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Tool as MCPTool } from "@modelcontextprotocol/sdk/types.js";
2
+
3
+ export interface MCPServerConfig {
4
+ id: string;
5
+ name: string;
6
+ url: string;
7
+ enabled: boolean;
8
+ transport: 'websocket' | 'sse' | 'streamable-http';
9
+ auth?: {
10
+ type: 'bearer' | 'basic' | 'oauth';
11
+ token?: string;
12
+ username?: string;
13
+ password?: string;
14
+ };
15
+ }
16
+
17
+ export interface MCPServerConnection {
18
+ config: MCPServerConfig;
19
+ isConnected: boolean;
20
+ tools: MCPTool[];
21
+ lastError?: string;
22
+ lastConnected?: Date;
23
+ }
24
+
25
+ // Extended Tool interface to support both local and MCP tools
26
+ export interface ExtendedTool {
27
+ id: number | string;
28
+ name: string;
29
+ enabled: boolean;
30
+ isCollapsed?: boolean;
31
+
32
+ // Local tool properties
33
+ code?: string;
34
+ renderer?: string;
35
+
36
+ // MCP tool properties
37
+ mcpServerId?: string;
38
+ mcpTool?: MCPTool;
39
+ isRemote?: boolean;
40
+ }
41
+
42
+ // MCP Tool execution result
43
+ export interface MCPToolResult {
44
+ content: Array<{
45
+ type: string;
46
+ text?: string;
47
+ data?: unknown;
48
+ mimeType?: string;
49
+ }>;
50
+ isError?: boolean;
51
+ }
52
+
53
+ // MCP Client state
54
+ export interface MCPClientState {
55
+ servers: Record<string, MCPServerConnection>;
56
+ isLoading: boolean;
57
+ error?: string;
58
+ }
src/utils/storage.ts ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Secure storage utilities for sensitive data like OAuth tokens
3
+ */
4
+
5
+ const ENCRYPTION_KEY_NAME = 'mcp-encryption-key';
6
+
7
+ // Generate or retrieve encryption key
8
+ async function getEncryptionKey(): Promise<CryptoKey> {
9
+ const keyData = localStorage.getItem(ENCRYPTION_KEY_NAME);
10
+
11
+ if (keyData) {
12
+ try {
13
+ const keyBuffer = new Uint8Array(JSON.parse(keyData));
14
+ return await crypto.subtle.importKey(
15
+ 'raw',
16
+ keyBuffer,
17
+ { name: 'AES-GCM' },
18
+ false,
19
+ ['encrypt', 'decrypt']
20
+ );
21
+ } catch {
22
+ // Key corrupted, generate new one
23
+ }
24
+ }
25
+
26
+ // Generate new key
27
+ const key = await crypto.subtle.generateKey(
28
+ { name: 'AES-GCM', length: 256 },
29
+ true,
30
+ ['encrypt', 'decrypt']
31
+ );
32
+
33
+ // Store key for future use
34
+ const keyBuffer = await crypto.subtle.exportKey('raw', key);
35
+ localStorage.setItem(ENCRYPTION_KEY_NAME, JSON.stringify(Array.from(new Uint8Array(keyBuffer))));
36
+
37
+ return key;
38
+ }
39
+
40
+ // Encrypt sensitive data
41
+ export async function encryptData(data: string): Promise<string> {
42
+ try {
43
+ const key = await getEncryptionKey();
44
+ const encoder = new TextEncoder();
45
+ const dataBuffer = encoder.encode(data);
46
+
47
+ const iv = crypto.getRandomValues(new Uint8Array(12));
48
+ const encryptedBuffer = await crypto.subtle.encrypt(
49
+ { name: 'AES-GCM', iv },
50
+ key,
51
+ dataBuffer
52
+ );
53
+
54
+ // Combine IV and encrypted data
55
+ const result = new Uint8Array(iv.length + encryptedBuffer.byteLength);
56
+ result.set(iv);
57
+ result.set(new Uint8Array(encryptedBuffer), iv.length);
58
+
59
+ return btoa(String.fromCharCode(...result));
60
+ } catch (error) {
61
+ console.warn('Encryption failed, storing data unencrypted:', error);
62
+ return data;
63
+ }
64
+ }
65
+
66
+ // Decrypt sensitive data
67
+ export async function decryptData(encryptedData: string): Promise<string> {
68
+ try {
69
+ const key = await getEncryptionKey();
70
+ const dataBuffer = new Uint8Array(
71
+ atob(encryptedData).split('').map(char => char.charCodeAt(0))
72
+ );
73
+
74
+ const iv = dataBuffer.slice(0, 12);
75
+ const encrypted = dataBuffer.slice(12);
76
+
77
+ const decryptedBuffer = await crypto.subtle.decrypt(
78
+ { name: 'AES-GCM', iv },
79
+ key,
80
+ encrypted
81
+ );
82
+
83
+ const decoder = new TextDecoder();
84
+ return decoder.decode(decryptedBuffer);
85
+ } catch (error) {
86
+ console.warn('Decryption failed, returning data as-is:', error);
87
+ return encryptedData;
88
+ }
89
+ }
90
+
91
+ // Secure storage wrapper for sensitive data
92
+ export const secureStorage = {
93
+ async setItem(key: string, value: string): Promise<void> {
94
+ const encrypted = await encryptData(value);
95
+ localStorage.setItem(`secure_${key}`, encrypted);
96
+ },
97
+
98
+ async getItem(key: string): Promise<string | null> {
99
+ const encrypted = localStorage.getItem(`secure_${key}`);
100
+ if (!encrypted) return null;
101
+
102
+ return await decryptData(encrypted);
103
+ },
104
+
105
+ removeItem(key: string): void {
106
+ localStorage.removeItem(`secure_${key}`);
107
+ }
108
+ };