shreyask commited on
Commit
220a682
·
verified ·
1 Parent(s): ae68eff
This view is limited to 50 files because it contains too many changes.   See raw diff
.gitattributes CHANGED
@@ -33,3 +33,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ public/thumbnail.png filter=lfs diff=lfs merge=lfs -text
37
+ *.png filter=lfs diff=lfs merge=lfs -text
.gitignore CHANGED
@@ -1,23 +1,25 @@
1
- # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2
-
3
- # dependencies
4
- /node_modules
5
- /.pnp
6
- .pnp.js
7
-
8
- # testing
9
- /coverage
10
-
11
- # production
12
- /build
13
-
14
- # misc
15
- .DS_Store
16
- .env.local
17
- .env.development.local
18
- .env.test.local
19
- .env.production.local
20
-
21
  npm-debug.log*
22
  yarn-debug.log*
23
  yarn-error.log*
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Logs
2
+ logs
3
+ *.log
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  npm-debug.log*
5
  yarn-debug.log*
6
  yarn-error.log*
7
+ pnpm-debug.log*
8
+ lerna-debug.log*
9
+
10
+ node_modules
11
+ dist
12
+ dist-ssr
13
+ *.local
14
+
15
+ # Editor directories and files
16
+ .vscode/*
17
+ !.vscode/extensions.json
18
+ .idea
19
+ .DS_Store
20
+ *.suo
21
+ *.ntvs*
22
+ *.njsproj
23
+ *.sln
24
+ *.sw?
25
+ package-lock.json
README.md CHANGED
@@ -4,16 +4,16 @@ emoji: 🐠
4
  colorFrom: indigo
5
  colorTo: red
6
  sdk: static
7
- pinned: false
8
  app_build_command: npm run build
9
  app_file: build/index.html
10
  license: apache-2.0
11
- short_description: Use MCP and LLM with WebGPU
12
  ---
13
 
14
- # Getting Started with Create React App
15
 
16
- This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
17
 
18
  ## Available Scripts
19
 
 
4
  colorFrom: indigo
5
  colorTo: red
6
  sdk: static
7
+ pinned: true
8
  app_build_command: npm run build
9
  app_file: build/index.html
10
  license: apache-2.0
11
+ short_description: Use MCP and WebGPU-based LLMs with tool calling
12
  ---
13
 
14
+ # WebGPU MCP
15
 
16
+ Run WebGPU-based language models with tool calling capabilities in your browser, powered by the Model Context Protocol (MCP). This project supports any WebGPU-compatible models from [Hugging Face ONNX Community](https://huggingface.co/onnx-community) that support tool calling.
17
 
18
  ## Available Scripts
19
 
eslint.config.js ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import js from "@eslint/js";
2
+ import globals from "globals";
3
+ import reactHooks from "eslint-plugin-react-hooks";
4
+ import reactRefresh from "eslint-plugin-react-refresh";
5
+ import tseslint from "typescript-eslint";
6
+ import { globalIgnores } from "eslint/config";
7
+
8
+ export default tseslint.config([
9
+ globalIgnores(["dist"]),
10
+ {
11
+ files: ["**/*.{ts,tsx}"],
12
+ extends: [
13
+ js.configs.recommended,
14
+ tseslint.configs.recommended,
15
+ reactHooks.configs["recommended-latest"],
16
+ reactRefresh.configs.vite,
17
+ ],
18
+ languageOptions: {
19
+ ecmaVersion: 2020,
20
+ globals: globals.browser,
21
+ },
22
+ },
23
+ ]);
index.html ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>WebGPU MCP - In-Browser Tool Calling</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="/src/main.tsx"></script>
11
+ </body>
12
+ </html>
package.json CHANGED
@@ -1,39 +1,37 @@
1
  {
2
- "name": "react-template",
3
- "version": "0.1.0",
4
  "private": true,
 
 
 
 
 
 
 
 
5
  "dependencies": {
6
- "@testing-library/dom": "^10.4.0",
7
- "@testing-library/jest-dom": "^6.6.3",
8
- "@testing-library/react": "^16.3.0",
9
- "@testing-library/user-event": "^13.5.0",
 
 
10
  "react": "^19.1.0",
11
  "react-dom": "^19.1.0",
12
- "react-scripts": "5.0.1",
13
- "web-vitals": "^2.1.4"
14
- },
15
- "scripts": {
16
- "start": "react-scripts start",
17
- "build": "react-scripts build",
18
- "test": "react-scripts test",
19
- "eject": "react-scripts eject"
20
- },
21
- "eslintConfig": {
22
- "extends": [
23
- "react-app",
24
- "react-app/jest"
25
- ]
26
  },
27
- "browserslist": {
28
- "production": [
29
- ">0.2%",
30
- "not dead",
31
- "not op_mini all"
32
- ],
33
- "development": [
34
- "last 1 chrome version",
35
- "last 1 firefox version",
36
- "last 1 safari version"
37
- ]
 
38
  }
39
  }
 
1
  {
2
+ "name": "mcp-webgpu",
 
3
  "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc -b && vite build",
9
+ "lint": "eslint .",
10
+ "preview": "vite preview"
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": {
25
+ "@eslint/js": "^9.30.1",
26
+ "@types/react": "^19.1.8",
27
+ "@types/react-dom": "^19.1.6",
28
+ "@vitejs/plugin-react": "^4.6.0",
29
+ "eslint": "^9.30.1",
30
+ "eslint-plugin-react-hooks": "^5.2.0",
31
+ "eslint-plugin-react-refresh": "^0.4.20",
32
+ "globals": "^16.3.0",
33
+ "typescript": "~5.8.3",
34
+ "typescript-eslint": "^8.35.1",
35
+ "vite": "^7.0.4"
36
  }
37
  }
public/favicon.ico DELETED
Binary file (3.87 kB)
 
public/index.html DELETED
@@ -1,43 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8" />
5
- <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
6
- <meta name="viewport" content="width=device-width, initial-scale=1" />
7
- <meta name="theme-color" content="#000000" />
8
- <meta
9
- name="description"
10
- content="Web site created using create-react-app"
11
- />
12
- <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
13
- <!--
14
- manifest.json provides metadata used when your web app is installed on a
15
- user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
16
- -->
17
- <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
18
- <!--
19
- Notice the use of %PUBLIC_URL% in the tags above.
20
- It will be replaced with the URL of the `public` folder during the build.
21
- Only files inside the `public` folder can be referenced from the HTML.
22
-
23
- Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
24
- work correctly both with client-side routing and a non-root public URL.
25
- Learn how to configure a non-root public URL by running `npm run build`.
26
- -->
27
- <title>React App</title>
28
- </head>
29
- <body>
30
- <noscript>You need to enable JavaScript to run this app.</noscript>
31
- <div id="root"></div>
32
- <!--
33
- This HTML file is a template.
34
- If you open it directly in the browser, you will see an empty page.
35
-
36
- You can add webfonts, meta tags, or analytics to this file.
37
- The build step will place the bundled scripts into the <body> tag.
38
-
39
- To begin the development, run `npm start` or `yarn start`.
40
- To create a production bundle, use `npm run build` or `yarn build`.
41
- -->
42
- </body>
43
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
public/liquidai-logo.svg ADDED
public/logo192.png DELETED
Binary file (5.35 kB)
 
public/logo512.png DELETED
Binary file (9.66 kB)
 
public/manifest.json DELETED
@@ -1,25 +0,0 @@
1
- {
2
- "short_name": "React App",
3
- "name": "Create React App Sample",
4
- "icons": [
5
- {
6
- "src": "favicon.ico",
7
- "sizes": "64x64 32x32 24x24 16x16",
8
- "type": "image/x-icon"
9
- },
10
- {
11
- "src": "logo192.png",
12
- "type": "image/png",
13
- "sizes": "192x192"
14
- },
15
- {
16
- "src": "logo512.png",
17
- "type": "image/png",
18
- "sizes": "512x512"
19
- }
20
- ],
21
- "start_url": ".",
22
- "display": "standalone",
23
- "theme_color": "#000000",
24
- "background_color": "#ffffff"
25
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
public/robots.txt DELETED
@@ -1,3 +0,0 @@
1
- # https://www.robotstxt.org/robotstxt.html
2
- User-agent: *
3
- Disallow:
 
 
 
 
public/thumbnail.png ADDED

Git LFS Details

  • SHA256: 389f1e853f6f9be5fcad644a6092e4283a004072598ffdba0d9da6b6d7d6d751
  • Pointer size: 131 Bytes
  • Size of remote file: 624 kB
src/App.css DELETED
@@ -1,38 +0,0 @@
1
- .App {
2
- text-align: center;
3
- }
4
-
5
- .App-logo {
6
- height: 40vmin;
7
- pointer-events: none;
8
- }
9
-
10
- @media (prefers-reduced-motion: no-preference) {
11
- .App-logo {
12
- animation: App-logo-spin infinite 20s linear;
13
- }
14
- }
15
-
16
- .App-header {
17
- background-color: #282c34;
18
- min-height: 100vh;
19
- display: flex;
20
- flex-direction: column;
21
- align-items: center;
22
- justify-content: center;
23
- font-size: calc(10px + 2vmin);
24
- color: white;
25
- }
26
-
27
- .App-link {
28
- color: #61dafb;
29
- }
30
-
31
- @keyframes App-logo-spin {
32
- from {
33
- transform: rotate(0deg);
34
- }
35
- to {
36
- transform: rotate(360deg);
37
- }
38
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/App.js DELETED
@@ -1,25 +0,0 @@
1
- import logo from './logo.svg';
2
- import './App.css';
3
-
4
- function App() {
5
- return (
6
- <div className="App">
7
- <header className="App-header">
8
- <img src={logo} className="App-logo" alt="logo" />
9
- <p>
10
- Edit <code>src/App.js</code> and save to reload.
11
- </p>
12
- <a
13
- className="App-link"
14
- href="https://reactjs.org"
15
- target="_blank"
16
- rel="noopener noreferrer"
17
- >
18
- Learn React
19
- </a>
20
- </header>
21
- </div>
22
- );
23
- }
24
-
25
- export default App;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/App.test.js DELETED
@@ -1,8 +0,0 @@
1
- import { render, screen } from '@testing-library/react';
2
- import App from './App';
3
-
4
- test('renders learn react link', () => {
5
- render(<App />);
6
- const linkElement = screen.getByText(/learn react/i);
7
- expect(linkElement).toBeInTheDocument();
8
- });
 
 
 
 
 
 
 
 
 
src/App.tsx ADDED
@@ -0,0 +1,925 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, {
2
+ useState,
3
+ useEffect,
4
+ useCallback,
5
+ useRef,
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
+
24
+ import {
25
+ parsePythonicCalls,
26
+ extractPythonicCalls,
27
+ extractFunctionAndRenderer,
28
+ generateSchemaFromCode,
29
+ extractToolCallContent,
30
+ mapArgsToNamedParams,
31
+ getErrorMessage,
32
+ isMobileOrTablet,
33
+ } from "./utils";
34
+
35
+ import { DEFAULT_SYSTEM_PROMPT } from "./constants/systemPrompt";
36
+ import { DB_NAME, STORE_NAME, SETTINGS_STORE_NAME } from "./constants/db";
37
+
38
+ import { TEMPLATE } from "./tools";
39
+ import ToolResultRenderer from "./components/ToolResultRenderer";
40
+ import ToolCallIndicator from "./components/ToolCallIndicator";
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
+
56
+ interface BaseMessage {
57
+ role: "system" | "user" | "assistant";
58
+ content: string;
59
+ }
60
+ interface ToolMessage {
61
+ role: "tool";
62
+ content: string;
63
+ renderInfo: RenderInfo[]; // Rich data for the UI
64
+ }
65
+ type Message = BaseMessage | ToolMessage;
66
+
67
+ async function getDB(): Promise<IDBPDatabase> {
68
+ return openDB(DB_NAME, 1, {
69
+ upgrade(db) {
70
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
71
+ db.createObjectStore(STORE_NAME, {
72
+ keyPath: "id",
73
+ autoIncrement: true,
74
+ });
75
+ }
76
+ if (!db.objectStoreNames.contains(SETTINGS_STORE_NAME)) {
77
+ db.createObjectStore(SETTINGS_STORE_NAME, { keyPath: "key" });
78
+ }
79
+ },
80
+ });
81
+ }
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);
89
+ const [tempSystemPrompt, setTempSystemPrompt] = useState<string>("");
90
+ const [messages, setMessages] = useState<Message[]>([]);
91
+ const [tools, setTools] = useState<Tool[]>([]);
92
+ const [input, setInput] = useState<string>("");
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);
105
+ const inputRef = useRef<HTMLInputElement>(null);
106
+ const {
107
+ isLoading,
108
+ isReady,
109
+ error,
110
+ progress,
111
+ loadModel,
112
+ generateResponse,
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);
126
+ setTools(allTools.map((t) => ({ ...t, isCollapsed: false })));
127
+
128
+ // Load MCP tools and merge them
129
+ const mcpTools = getMCPToolsAsOriginalTools();
130
+ setTools((prevTools) => [...prevTools, ...mcpTools]);
131
+ }, [getMCPToolsAsOriginalTools]);
132
+
133
+ useEffect(() => {
134
+ loadTools();
135
+ // Connect to MCP servers on startup
136
+ connectAllMCPServers().catch((error) => {
137
+ console.error("Failed to connect to MCP servers:", error);
138
+ });
139
+ }, [loadTools, connectAllMCPServers]);
140
+
141
+ useEffect(() => {
142
+ if (chatContainerRef.current) {
143
+ chatContainerRef.current.scrollTop =
144
+ chatContainerRef.current.scrollHeight;
145
+ }
146
+ }, [messages]);
147
+
148
+ const updateToolInDB = async (tool: Tool): Promise<void> => {
149
+ const db = await getDB();
150
+ await db.put(STORE_NAME, tool);
151
+ };
152
+
153
+ const saveToolDebounced = (tool: Tool): void => {
154
+ if (tool.id !== undefined && debounceTimers.current[tool.id]) {
155
+ clearTimeout(debounceTimers.current[tool.id]);
156
+ }
157
+ if (tool.id !== undefined) {
158
+ debounceTimers.current[tool.id] = setTimeout(() => {
159
+ updateToolInDB(tool);
160
+ }, 300);
161
+ }
162
+ };
163
+
164
+ const clearChat = useCallback(() => {
165
+ setMessages([]);
166
+ clearPastKeyValues();
167
+ }, [clearPastKeyValues]);
168
+
169
+ const addTool = async (): Promise<void> => {
170
+ const newTool: Omit<Tool, "id"> = {
171
+ name: "new_tool",
172
+ code: TEMPLATE,
173
+ enabled: true,
174
+ isCollapsed: false,
175
+ };
176
+ const db = await getDB();
177
+ const id = await db.add(STORE_NAME, newTool);
178
+ setTools((prev) => {
179
+ const updated = [...prev, { ...newTool, id: id as number }];
180
+ setTimeout(() => {
181
+ if (toolsContainerRef.current) {
182
+ toolsContainerRef.current.scrollTop =
183
+ toolsContainerRef.current.scrollHeight;
184
+ }
185
+ }, 0);
186
+ return updated;
187
+ });
188
+ clearChat();
189
+ };
190
+
191
+ const deleteTool = async (id: number): Promise<void> => {
192
+ if (debounceTimers.current[id]) {
193
+ clearTimeout(debounceTimers.current[id]);
194
+ }
195
+ const db = await getDB();
196
+ await db.delete(STORE_NAME, id);
197
+ setTools(tools.filter((tool) => tool.id !== id));
198
+ clearChat();
199
+ };
200
+
201
+ const toggleToolEnabled = (id: number): void => {
202
+ let changedTool: Tool | undefined;
203
+ const newTools = tools.map((tool) => {
204
+ if (tool.id === id) {
205
+ changedTool = { ...tool, enabled: !tool.enabled };
206
+ return changedTool;
207
+ }
208
+ return tool;
209
+ });
210
+ setTools(newTools);
211
+ if (changedTool) saveToolDebounced(changedTool);
212
+ };
213
+
214
+ const toggleToolCollapsed = (id: number): void => {
215
+ setTools(
216
+ tools.map((tool) =>
217
+ tool.id === id ? { ...tool, isCollapsed: !tool.isCollapsed } : tool
218
+ )
219
+ );
220
+ };
221
+
222
+ const expandTool = (id: number): void => {
223
+ setTools(
224
+ tools.map((tool) =>
225
+ tool.id === id ? { ...tool, isCollapsed: false } : tool
226
+ )
227
+ );
228
+ };
229
+
230
+ const handleToolCodeChange = (id: number, newCode: string): void => {
231
+ let changedTool: Tool | undefined;
232
+ const newTools = tools.map((tool) => {
233
+ if (tool.id === id) {
234
+ const { functionCode } = extractFunctionAndRenderer(newCode);
235
+ const schema = generateSchemaFromCode(functionCode);
236
+ changedTool = { ...tool, code: newCode, name: schema.name };
237
+ return changedTool;
238
+ }
239
+ return tool;
240
+ });
241
+ setTools(newTools);
242
+ if (changedTool) saveToolDebounced(changedTool);
243
+ };
244
+
245
+ const executeToolCall = async (callString: string): Promise<string> => {
246
+ const parsedCall = parsePythonicCalls(callString);
247
+ if (!parsedCall) throw new Error(`Invalid tool call format: ${callString}`);
248
+
249
+ const { name, positionalArgs, keywordArgs } = parsedCall;
250
+ const toolToUse = tools.find((t) => t.name === name && t.enabled);
251
+ if (!toolToUse) throw new Error(`Tool '${name}' not found or is disabled.`);
252
+
253
+ // Check if this is an MCP tool
254
+ const isMCPTool = toolToUse.code?.includes("mcpServerId:");
255
+ if (isMCPTool) {
256
+ // Extract MCP server ID and tool name from the code
257
+ const mcpServerMatch = toolToUse.code?.match(/mcpServerId: "([^"]+)"/);
258
+ const mcpToolMatch = toolToUse.code?.match(/toolName: "([^"]+)"/);
259
+
260
+ if (mcpServerMatch && mcpToolMatch) {
261
+ const serverId = mcpServerMatch[1];
262
+ const toolName = mcpToolMatch[1];
263
+
264
+ // Convert positional and keyword args to a single args object
265
+ const { functionCode } = extractFunctionAndRenderer(toolToUse.code);
266
+ const schema = generateSchemaFromCode(functionCode);
267
+ const paramNames = Object.keys(schema.parameters.properties);
268
+
269
+ const args: Record<string, unknown> = {};
270
+
271
+ // Map positional args
272
+ for (
273
+ let i = 0;
274
+ i < Math.min(positionalArgs.length, paramNames.length);
275
+ i++
276
+ ) {
277
+ args[paramNames[i]] = positionalArgs[i];
278
+ }
279
+
280
+ // Map keyword args
281
+ Object.entries(keywordArgs).forEach(([key, value]) => {
282
+ args[key] = value;
283
+ });
284
+
285
+ // Call MCP tool
286
+ const result = await callMCPTool(serverId, toolName, args);
287
+ return JSON.stringify(result);
288
+ }
289
+ }
290
+
291
+ // Handle local tools as before
292
+ const { functionCode } = extractFunctionAndRenderer(toolToUse.code);
293
+ const schema = generateSchemaFromCode(functionCode);
294
+ const paramNames = Object.keys(schema.parameters.properties);
295
+
296
+ const finalArgs: unknown[] = [];
297
+ const requiredParams = schema.parameters.required || [];
298
+
299
+ for (let i = 0; i < paramNames.length; ++i) {
300
+ const paramName = paramNames[i];
301
+ if (i < positionalArgs.length) {
302
+ finalArgs.push(positionalArgs[i]);
303
+ } else if (Object.prototype.hasOwnProperty.call(keywordArgs, paramName)) {
304
+ finalArgs.push(keywordArgs[paramName]);
305
+ } else if (
306
+ Object.prototype.hasOwnProperty.call(
307
+ schema.parameters.properties[paramName],
308
+ "default"
309
+ )
310
+ ) {
311
+ finalArgs.push(schema.parameters.properties[paramName].default);
312
+ } else if (!requiredParams.includes(paramName)) {
313
+ finalArgs.push(undefined);
314
+ } else {
315
+ throw new Error(`Missing required argument: ${paramName}`);
316
+ }
317
+ }
318
+
319
+ const bodyMatch = functionCode.match(/function[^{]+\{([\s\S]*)\}/);
320
+ if (!bodyMatch) {
321
+ throw new Error(
322
+ "Could not parse function body. Ensure it's a standard `function` declaration."
323
+ );
324
+ }
325
+ const body = bodyMatch[1];
326
+ const AsyncFunction = Object.getPrototypeOf(
327
+ async function () {}
328
+ ).constructor;
329
+ const func = new AsyncFunction(...paramNames, body);
330
+ const result = await func(...finalArgs);
331
+ return JSON.stringify(result);
332
+ };
333
+
334
+ const executeToolCalls = async (
335
+ toolCallContent: string
336
+ ): Promise<RenderInfo[]> => {
337
+ const toolCalls = extractPythonicCalls(toolCallContent);
338
+ if (toolCalls.length === 0)
339
+ return [{ call: "", error: "No valid tool calls found." }];
340
+
341
+ const results: RenderInfo[] = [];
342
+ for (const call of toolCalls) {
343
+ try {
344
+ const result = await executeToolCall(call);
345
+ const parsedCall = parsePythonicCalls(call);
346
+ const toolUsed = parsedCall
347
+ ? tools.find((t) => t.name === parsedCall.name && t.enabled)
348
+ : null;
349
+ const { rendererCode } = toolUsed
350
+ ? extractFunctionAndRenderer(toolUsed.code)
351
+ : { rendererCode: undefined };
352
+
353
+ let parsedResult;
354
+ try {
355
+ parsedResult = JSON.parse(result);
356
+ } catch {
357
+ parsedResult = result;
358
+ }
359
+
360
+ let namedParams: Record<string, unknown> = Object.create(null);
361
+ if (parsedCall && toolUsed) {
362
+ const schema = generateSchemaFromCode(
363
+ extractFunctionAndRenderer(toolUsed.code).functionCode
364
+ );
365
+ const paramNames = Object.keys(schema.parameters.properties);
366
+ namedParams = mapArgsToNamedParams(
367
+ paramNames,
368
+ parsedCall.positionalArgs,
369
+ parsedCall.keywordArgs
370
+ );
371
+ }
372
+
373
+ const renderInfo: RenderInfo = {
374
+ call,
375
+ result: parsedResult,
376
+ renderer: rendererCode,
377
+ };
378
+ if (namedParams && Object.keys(namedParams).length > 0) {
379
+ renderInfo.input = namedParams;
380
+ }
381
+ results.push(renderInfo);
382
+ } catch (error) {
383
+ const errorMessage = getErrorMessage(error);
384
+ results.push({ call, error: errorMessage });
385
+ }
386
+ }
387
+ return results;
388
+ };
389
+
390
+ const handleSendMessage = async (): Promise<void> => {
391
+ if (!input.trim() || !isReady) return;
392
+
393
+ const userMessage: Message = { role: "user", content: input };
394
+ const currentMessages: Message[] = [...messages, userMessage];
395
+ setMessages(currentMessages);
396
+ setInput("");
397
+ setIsGenerating(true);
398
+
399
+ try {
400
+ const toolSchemas = tools
401
+ .filter((tool) => tool.enabled)
402
+ .map((tool) => generateSchemaFromCode(tool.code));
403
+
404
+ while (true) {
405
+ const messagesForGeneration = [
406
+ { role: "system" as const, content: systemPrompt },
407
+ ...currentMessages,
408
+ ];
409
+
410
+ setMessages([...currentMessages, { role: "assistant", content: "" }]);
411
+
412
+ let accumulatedContent = "";
413
+ const response = await generateResponse(
414
+ messagesForGeneration,
415
+ toolSchemas,
416
+ (token: string) => {
417
+ accumulatedContent += token;
418
+ setMessages((current) => {
419
+ const updated = [...current];
420
+ updated[updated.length - 1] = {
421
+ role: "assistant",
422
+ content: accumulatedContent,
423
+ };
424
+ return updated;
425
+ });
426
+ }
427
+ );
428
+
429
+ currentMessages.push({ role: "assistant", content: response });
430
+ const toolCallContent = extractToolCallContent(response);
431
+
432
+ if (toolCallContent) {
433
+ const toolResults = await executeToolCalls(toolCallContent);
434
+
435
+ const toolMessage: ToolMessage = {
436
+ role: "tool",
437
+ content: JSON.stringify(toolResults.map((r) => r.result ?? null)),
438
+ renderInfo: toolResults,
439
+ };
440
+ currentMessages.push(toolMessage);
441
+ setMessages([...currentMessages]);
442
+ continue;
443
+ } else {
444
+ setMessages(currentMessages);
445
+ break;
446
+ }
447
+ }
448
+ } catch (error) {
449
+ const errorMessage = getErrorMessage(error);
450
+ setMessages([
451
+ ...currentMessages,
452
+ {
453
+ role: "assistant",
454
+ content: `Error generating response: ${errorMessage}`,
455
+ },
456
+ ]);
457
+ } finally {
458
+ setIsGenerating(false);
459
+ setTimeout(() => inputRef.current?.focus(), 0);
460
+ }
461
+ };
462
+
463
+ const loadSystemPrompt = useCallback(async (): Promise<void> => {
464
+ try {
465
+ const db = await getDB();
466
+ const stored = await db.get(SETTINGS_STORE_NAME, "systemPrompt");
467
+ if (stored && stored.value) setSystemPrompt(stored.value);
468
+ } catch (error) {
469
+ console.error("Failed to load system prompt:", error);
470
+ }
471
+ }, []);
472
+
473
+ const saveSystemPrompt = useCallback(
474
+ async (prompt: string): Promise<void> => {
475
+ try {
476
+ const db = await getDB();
477
+ await db.put(SETTINGS_STORE_NAME, {
478
+ key: "systemPrompt",
479
+ value: prompt,
480
+ });
481
+ } catch (error) {
482
+ console.error("Failed to save system prompt:", error);
483
+ }
484
+ },
485
+ []
486
+ );
487
+
488
+ const loadSelectedModel = useCallback(async (): Promise<void> => {
489
+ try {
490
+ await loadModel();
491
+ } catch (error) {
492
+ console.error("Failed to load model:", error);
493
+ }
494
+ }, [loadModel]);
495
+
496
+ const loadSelectedModelId = useCallback(async (): Promise<void> => {
497
+ try {
498
+ const db = await getDB();
499
+ const stored = await db.get(SETTINGS_STORE_NAME, "selectedModelId");
500
+ if (stored && stored.value) {
501
+ setSelectedModelId(stored.value);
502
+ }
503
+ } catch (error) {
504
+ console.error("Failed to load selected model ID:", error);
505
+ }
506
+ }, []);
507
+
508
+ useEffect(() => {
509
+ loadSystemPrompt();
510
+ }, [loadSystemPrompt]);
511
+
512
+ const handleOpenSystemPromptModal = (): void => {
513
+ setTempSystemPrompt(systemPrompt);
514
+ setIsSystemPromptModalOpen(true);
515
+ };
516
+
517
+ const handleSaveSystemPrompt = (): void => {
518
+ setSystemPrompt(tempSystemPrompt);
519
+ saveSystemPrompt(tempSystemPrompt);
520
+ setIsSystemPromptModalOpen(false);
521
+ };
522
+
523
+ const handleCancelSystemPrompt = (): void => {
524
+ setTempSystemPrompt("");
525
+ setIsSystemPromptModalOpen(false);
526
+ };
527
+
528
+ const handleResetSystemPrompt = (): void => {
529
+ setTempSystemPrompt(DEFAULT_SYSTEM_PROMPT);
530
+ };
531
+
532
+ const saveSelectedModel = useCallback(
533
+ async (modelId: string): Promise<void> => {
534
+ try {
535
+ const db = await getDB();
536
+ await db.put(SETTINGS_STORE_NAME, {
537
+ key: "selectedModelId",
538
+ value: modelId,
539
+ });
540
+ } catch (error) {
541
+ console.error("Failed to save selected model ID:", error);
542
+ }
543
+ },
544
+ []
545
+ );
546
+
547
+ useEffect(() => {
548
+ loadSystemPrompt();
549
+ loadSelectedModelId();
550
+ }, [loadSystemPrompt, loadSelectedModelId]);
551
+
552
+ const handleModelSelect = async (modelId: string) => {
553
+ setSelectedModelId(modelId);
554
+ setIsModelDropdownOpen(false);
555
+ await saveSelectedModel(modelId);
556
+ };
557
+
558
+ const handleExampleClick = async (messageText: string): Promise<void> => {
559
+ if (!isReady || isGenerating) return;
560
+ setInput(messageText);
561
+
562
+ const userMessage: Message = { role: "user", content: messageText };
563
+ const currentMessages: Message[] = [...messages, userMessage];
564
+ setMessages(currentMessages);
565
+ setInput("");
566
+ setIsGenerating(true);
567
+
568
+ try {
569
+ const toolSchemas = tools
570
+ .filter((tool) => tool.enabled)
571
+ .map((tool) => generateSchemaFromCode(tool.code));
572
+
573
+ while (true) {
574
+ const messagesForGeneration = [
575
+ { role: "system" as const, content: systemPrompt },
576
+ ...currentMessages,
577
+ ];
578
+
579
+ setMessages([...currentMessages, { role: "assistant", content: "" }]);
580
+
581
+ let accumulatedContent = "";
582
+ const response = await generateResponse(
583
+ messagesForGeneration,
584
+ toolSchemas,
585
+ (token: string) => {
586
+ accumulatedContent += token;
587
+ setMessages((current) => {
588
+ const updated = [...current];
589
+ updated[updated.length - 1] = {
590
+ role: "assistant",
591
+ content: accumulatedContent,
592
+ };
593
+ return updated;
594
+ });
595
+ }
596
+ );
597
+
598
+ currentMessages.push({ role: "assistant", content: response });
599
+ const toolCallContent = extractToolCallContent(response);
600
+
601
+ if (toolCallContent) {
602
+ const toolResults = await executeToolCalls(toolCallContent);
603
+
604
+ const toolMessage: ToolMessage = {
605
+ role: "tool",
606
+ content: JSON.stringify(toolResults.map((r) => r.result ?? null)),
607
+ renderInfo: toolResults,
608
+ };
609
+ currentMessages.push(toolMessage);
610
+ setMessages([...currentMessages]);
611
+ continue;
612
+ } else {
613
+ setMessages(currentMessages);
614
+ break;
615
+ }
616
+ }
617
+ } catch (error) {
618
+ const errorMessage = getErrorMessage(error);
619
+ setMessages([
620
+ ...currentMessages,
621
+ {
622
+ role: "assistant",
623
+ content: `Error generating response: ${errorMessage}`,
624
+ },
625
+ ]);
626
+ } finally {
627
+ setIsGenerating(false);
628
+ setTimeout(() => inputRef.current?.focus(), 0);
629
+ }
630
+ };
631
+
632
+ return (
633
+ <div className="font-sans bg-gray-900">
634
+ {!isReady ? (
635
+ <LoadingScreen
636
+ isLoading={isLoading}
637
+ progress={progress}
638
+ error={error}
639
+ loadSelectedModel={loadSelectedModel}
640
+ selectedModelId={selectedModelId}
641
+ isModelDropdownOpen={isModelDropdownOpen}
642
+ setIsModelDropdownOpen={setIsModelDropdownOpen}
643
+ handleModelSelect={handleModelSelect}
644
+ />
645
+ ) : (
646
+ <div className="flex h-screen text-white">
647
+ <div
648
+ className={`flex flex-col p-4 transition-all duration-300 ${
649
+ isToolsPanelVisible ? "w-1/2" : "w-full"
650
+ }`}
651
+ >
652
+ <div className="flex items-center justify-between mb-4">
653
+ <div className="flex items-center gap-3">
654
+ <h1 className="text-3xl font-bold text-gray-200">WebGPU MCP</h1>
655
+ </div>
656
+ <div className="flex items-center gap-3">
657
+ <div className="flex items-center text-green-400">
658
+ <Zap size={16} className="mr-2" />
659
+ Ready
660
+ </div>
661
+ <button
662
+ disabled={isGenerating}
663
+ onClick={clearChat}
664
+ className={`h-10 flex items-center px-3 py-2 rounded-lg font-bold transition-colors text-sm ${
665
+ isGenerating
666
+ ? "bg-gray-600 cursor-not-allowed opacity-50"
667
+ : "bg-gray-600 hover:bg-gray-700"
668
+ }`}
669
+ title="Clear chat"
670
+ >
671
+ <RotateCcw size={14} className="mr-2" /> Clear
672
+ </button>
673
+ <button
674
+ onClick={handleOpenSystemPromptModal}
675
+ 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"
676
+ title="Edit system prompt"
677
+ >
678
+ <Settings size={16} />
679
+ </button>
680
+ <button
681
+ onClick={() => setIsMCPManagerOpen(true)}
682
+ 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"
683
+ title="Manage MCP Servers"
684
+ >
685
+ 🌐
686
+ </button>
687
+ <button
688
+ onClick={() => setIsToolsPanelVisible(!isToolsPanelVisible)}
689
+ 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"
690
+ title={
691
+ isToolsPanelVisible
692
+ ? "Hide Tools Panel"
693
+ : "Show Tools Panel"
694
+ }
695
+ >
696
+ {isToolsPanelVisible ? (
697
+ <PanelRightClose size={16} />
698
+ ) : (
699
+ <PanelRightOpen size={16} />
700
+ )}
701
+ </button>
702
+ </div>
703
+ </div>
704
+
705
+ <div
706
+ ref={chatContainerRef}
707
+ className="flex-grow bg-gray-800 rounded-lg p-4 overflow-y-auto mb-4 space-y-4"
708
+ >
709
+ {messages.length === 0 && isReady ? (
710
+ <ExamplePrompts
711
+ examples={tools
712
+ .filter((tool) => tool.enabled)
713
+ .map((tool) => ({
714
+ icon: "🛠️",
715
+ displayText: tool.name,
716
+ messageText: `${tool.name}()`,
717
+ }))
718
+ .filter((ex) => ex.displayText)}
719
+ onExampleClick={handleExampleClick}
720
+ />
721
+ ) : (
722
+ messages.map((msg, index) => {
723
+ const key = `${msg.role}-${index}`;
724
+
725
+ if (msg.role === "user") {
726
+ return (
727
+ <div key={key} className="flex justify-end">
728
+ <div className="p-3 rounded-lg max-w-md bg-indigo-600">
729
+ <p className="text-sm whitespace-pre-wrap">
730
+ {msg.content}
731
+ </p>
732
+ </div>
733
+ </div>
734
+ );
735
+ } else if (msg.role === "assistant") {
736
+ const isToolCall = msg.content.includes(
737
+ "<|tool_call_start|>"
738
+ );
739
+
740
+ if (isToolCall) {
741
+ const nextMessage = messages[index + 1];
742
+ const isCompleted = nextMessage?.role === "tool";
743
+ const hasError =
744
+ isCompleted &&
745
+ (nextMessage as ToolMessage).renderInfo.some(
746
+ (info) => !!info.error
747
+ );
748
+
749
+ return (
750
+ <div key={key} className="flex justify-start">
751
+ <div className="p-3 rounded-lg bg-gray-700">
752
+ <ToolCallIndicator
753
+ content={msg.content}
754
+ isRunning={!isCompleted}
755
+ hasError={hasError}
756
+ />
757
+ </div>
758
+ </div>
759
+ );
760
+ }
761
+
762
+ return (
763
+ <div key={key} className="flex justify-start">
764
+ <div className="p-3 rounded-lg max-w-md bg-gray-700">
765
+ <p className="text-sm whitespace-pre-wrap">
766
+ {msg.content}
767
+ </p>
768
+ </div>
769
+ </div>
770
+ );
771
+ } else if (msg.role === "tool") {
772
+ const visibleToolResults = msg.renderInfo.filter(
773
+ (info) =>
774
+ info.error || (info.result != null && info.renderer)
775
+ );
776
+
777
+ if (visibleToolResults.length === 0) return null;
778
+
779
+ return (
780
+ <div key={key} className="flex justify-start">
781
+ <div className="p-3 rounded-lg bg-gray-700 max-w-lg">
782
+ <div className="space-y-3">
783
+ {visibleToolResults.map((info, idx) => (
784
+ <div className="flex flex-col gap-2" key={idx}>
785
+ <div className="text-xs text-gray-400 font-mono">
786
+ {info.call}
787
+ </div>
788
+ {info.error ? (
789
+ <ResultBlock error={info.error} />
790
+ ) : (
791
+ <ToolResultRenderer
792
+ result={info.result}
793
+ rendererCode={info.renderer}
794
+ input={info.input}
795
+ />
796
+ )}
797
+ </div>
798
+ ))}
799
+ </div>
800
+ </div>
801
+ </div>
802
+ );
803
+ }
804
+ return null;
805
+ })
806
+ )}
807
+ </div>
808
+
809
+ <div className="flex">
810
+ <input
811
+ ref={inputRef}
812
+ type="text"
813
+ value={input}
814
+ onChange={(e) => setInput(e.target.value)}
815
+ onKeyDown={(e) =>
816
+ e.key === "Enter" &&
817
+ !isGenerating &&
818
+ isReady &&
819
+ handleSendMessage()
820
+ }
821
+ disabled={isGenerating || !isReady}
822
+ className="flex-grow bg-gray-700 rounded-l-lg p-3 focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:opacity-50"
823
+ placeholder={
824
+ isReady
825
+ ? "Type your message here..."
826
+ : "Load model first to enable chat"
827
+ }
828
+ />
829
+ <button
830
+ onClick={handleSendMessage}
831
+ disabled={isGenerating || !isReady}
832
+ className="bg-indigo-600 hover:bg-indigo-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white font-bold p-3 rounded-r-lg transition-colors"
833
+ >
834
+ <Play size={20} />
835
+ </button>
836
+ </div>
837
+ </div>
838
+
839
+ {isToolsPanelVisible && (
840
+ <div className="w-1/2 flex flex-col p-4 border-l border-gray-700 transition-all duration-300">
841
+ <div className="flex justify-between items-center mb-4">
842
+ <h2 className="text-2xl font-bold text-teal-400">Tools</h2>
843
+ <button
844
+ onClick={addTool}
845
+ className="flex items-center bg-teal-600 hover:bg-teal-700 text-white font-bold py-2 px-4 rounded-lg transition-colors"
846
+ >
847
+ <Plus size={16} className="mr-2" /> Add Tool
848
+ </button>
849
+ </div>
850
+ <div
851
+ ref={toolsContainerRef}
852
+ className="flex-grow bg-gray-800 rounded-lg p-4 overflow-y-auto space-y-3"
853
+ >
854
+ {tools.map((tool) => (
855
+ <ToolItem
856
+ key={tool.id}
857
+ tool={tool}
858
+ onToggleEnabled={() => toggleToolEnabled(tool.id)}
859
+ onToggleCollapsed={() => toggleToolCollapsed(tool.id)}
860
+ onExpand={() => expandTool(tool.id)}
861
+ onDelete={() => deleteTool(tool.id)}
862
+ onCodeChange={(newCode) =>
863
+ handleToolCodeChange(tool.id, newCode)
864
+ }
865
+ />
866
+ ))}
867
+ </div>
868
+ </div>
869
+ )}
870
+ </div>
871
+ )}
872
+
873
+ {isSystemPromptModalOpen && (
874
+ <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
875
+ <div className="bg-gray-800 rounded-lg p-6 w-3/4 max-w-4xl max-h-3/4 flex flex-col text-gray-100">
876
+ <div className="flex justify-between items-center mb-4">
877
+ <h2 className="text-xl font-bold text-indigo-400">
878
+ Edit System Prompt
879
+ </h2>
880
+ <button
881
+ onClick={handleCancelSystemPrompt}
882
+ className="text-gray-400 hover:text-white"
883
+ >
884
+ <X size={20} />
885
+ </button>
886
+ </div>
887
+ <div className="flex-grow mb-4">
888
+ <textarea
889
+ value={tempSystemPrompt}
890
+ onChange={(e) => setTempSystemPrompt(e.target.value)}
891
+ className="w-full h-full bg-gray-700 text-white p-4 rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-indigo-500"
892
+ placeholder="Enter your system prompt here..."
893
+ style={{ minHeight: "300px" }}
894
+ />
895
+ </div>
896
+ <div className="flex justify-between">
897
+ <button
898
+ onClick={handleResetSystemPrompt}
899
+ className="px-4 py-2 bg-teal-600 hover:bg-teal-700 rounded-lg transition-colors"
900
+ >
901
+ Reset
902
+ </button>
903
+ <div className="flex gap-3">
904
+ <button
905
+ onClick={handleSaveSystemPrompt}
906
+ className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 rounded-lg transition-colors"
907
+ >
908
+ Save
909
+ </button>
910
+ </div>
911
+ </div>
912
+ </div>
913
+ </div>
914
+ )}
915
+
916
+ {/* MCP Server Manager Modal */}
917
+ <MCPServerManager
918
+ isOpen={isMCPManagerOpen}
919
+ onClose={() => setIsMCPManagerOpen(false)}
920
+ />
921
+ </div>
922
+ );
923
+ };
924
+
925
+ export default App;
src/components/ExamplePrompts.tsx ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type React from "react";
2
+ import { DEFAULT_EXAMPLES, type Example } from "../constants/examples";
3
+
4
+ interface ExamplePromptsProps {
5
+ examples?: Example[];
6
+ onExampleClick: (messageText: string) => void;
7
+ }
8
+
9
+ const ExamplePrompts: React.FC<ExamplePromptsProps> = ({
10
+ examples,
11
+ onExampleClick,
12
+ }) => {
13
+ // If examples are provided, use them. Otherwise, generate from enabled tools.
14
+ let dynamicExamples = examples;
15
+ if (!dynamicExamples) {
16
+ // Try to get tools from props (if passed as examples)
17
+ dynamicExamples = undefined;
18
+ }
19
+ // If still undefined, fallback to DEFAULT_EXAMPLES
20
+ if (!dynamicExamples) {
21
+ dynamicExamples = DEFAULT_EXAMPLES;
22
+ }
23
+
24
+ return (
25
+ <div className="flex flex-col items-center justify-center h-full space-y-6">
26
+ <div className="text-center mb-6">
27
+ <h2 className="text-2xl font-semibold text-gray-300 mb-1">
28
+ Try an example
29
+ </h2>
30
+ <p className="text-sm text-gray-500">Click one to get started</p>
31
+ </div>
32
+
33
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-3 max-w-2xl w-full px-4">
34
+ {dynamicExamples.map((example, index) => (
35
+ <button
36
+ key={index}
37
+ onClick={() => onExampleClick(example.messageText)}
38
+ className="flex items-center gap-3 p-4 bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors text-left group cursor-pointer"
39
+ >
40
+ <span className="text-xl flex-shrink-0 group-hover:scale-110 transition-transform">
41
+ {example.icon}
42
+ </span>
43
+ <span className="text-sm text-gray-200 group-hover:text-white transition-colors">
44
+ {example.displayText}
45
+ </span>
46
+ </button>
47
+ ))}
48
+ </div>
49
+ </div>
50
+ );
51
+ };
52
+
53
+ export default ExamplePrompts;
src/components/LoadingScreen.tsx ADDED
@@ -0,0 +1,538 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ChevronDown } from "lucide-react";
2
+ import { MODEL_OPTIONS } from "../constants/models";
3
+ import HfLogo from "./icons/HfLogo";
4
+ import MCPLogo from "./icons/MCPLogo";
5
+ import { useEffect, useMemo, useRef, useState } from "react";
6
+ import ReactDOM from "react-dom";
7
+
8
+ type Dot = {
9
+ x: number;
10
+ y: number;
11
+ radius: number;
12
+ speed: number;
13
+ opacity: number;
14
+ blur: number;
15
+ pulse: number;
16
+ pulseSpeed: number;
17
+ };
18
+ export const LoadingScreen = ({
19
+ isLoading,
20
+ progress,
21
+ error,
22
+ loadSelectedModel,
23
+ selectedModelId,
24
+ isModelDropdownOpen,
25
+ setIsModelDropdownOpen,
26
+ handleModelSelect,
27
+ }: {
28
+ isLoading: boolean;
29
+ progress: number;
30
+ error: string | null;
31
+ loadSelectedModel: () => void;
32
+ selectedModelId: string;
33
+ isModelDropdownOpen: boolean;
34
+ setIsModelDropdownOpen: (isOpen: boolean) => void;
35
+ handleModelSelect: (modelId: string) => void;
36
+ }) => {
37
+ const model = useMemo(
38
+ () => MODEL_OPTIONS.find((opt) => opt.id === selectedModelId),
39
+ [selectedModelId]
40
+ );
41
+
42
+ // Refs
43
+ const canvasRef = useRef<HTMLCanvasElement>(null);
44
+ const dropdownBtnRef = useRef<HTMLButtonElement>(null);
45
+ const dropdownRef = useRef<HTMLDivElement>(null);
46
+ const wrapperRef = useRef<HTMLDivElement>(null); // NEW: anchor for centering
47
+
48
+ // For keyboard navigation
49
+ const [activeIndex, setActiveIndex] = useState(
50
+ Math.max(
51
+ 0,
52
+ MODEL_OPTIONS.findIndex((m) => m.id === selectedModelId)
53
+ )
54
+ );
55
+
56
+ // Background Animation Effect (crisper dots + reduced motion)
57
+ useEffect(() => {
58
+ const canvas = canvasRef.current;
59
+ if (!canvas) return;
60
+
61
+ const ctx = canvas.getContext("2d");
62
+ if (!ctx) return;
63
+
64
+ const prefersReduced =
65
+ typeof window !== "undefined" &&
66
+ window.matchMedia &&
67
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches;
68
+
69
+ let animationFrameId: number;
70
+ let dots: Dot[] = [];
71
+
72
+ const setup = () => {
73
+ const dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
74
+ const { innerWidth, innerHeight } = window;
75
+ canvas.width = Math.floor(innerWidth * dpr);
76
+ canvas.height = Math.floor(innerHeight * dpr);
77
+ canvas.style.width = `${innerWidth}px`;
78
+ canvas.style.height = `${innerHeight}px`;
79
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
80
+
81
+ dots = [];
82
+ const numDots = Math.floor((innerWidth * innerHeight) / 12000);
83
+ for (let i = 0; i < numDots; ++i) {
84
+ dots.push({
85
+ x: Math.random() * innerWidth,
86
+ y: Math.random() * innerHeight,
87
+ radius: Math.random() * 2 + 0.3,
88
+ speed: prefersReduced ? 0 : Math.random() * 0.3 + 0.05,
89
+ opacity: Math.random() * 0.4 + 0.1,
90
+ blur: Math.random() > 0.8 ? Math.random() * 1.5 + 0.5 : 0,
91
+ pulse: Math.random() * Math.PI * 2,
92
+ pulseSpeed: prefersReduced ? 0 : Math.random() * 0.02 + 0.01,
93
+ });
94
+ }
95
+ };
96
+
97
+ const draw = () => {
98
+ if (!ctx) return;
99
+ const width = canvas.clientWidth;
100
+ const height = canvas.clientHeight;
101
+ ctx.clearRect(0, 0, width, height);
102
+
103
+ dots.forEach((dot) => {
104
+ dot.y += dot.speed;
105
+ dot.pulse += dot.pulseSpeed;
106
+
107
+ if (dot.y > height + dot.radius) {
108
+ dot.y = -dot.radius;
109
+ dot.x = Math.random() * width;
110
+ }
111
+
112
+ const pulseFactor = 1 + Math.sin(dot.pulse) * 0.2;
113
+ const currentRadius = dot.radius * pulseFactor;
114
+ const currentOpacity = dot.opacity * (0.8 + Math.sin(dot.pulse) * 0.2);
115
+
116
+ ctx.beginPath();
117
+ ctx.arc(dot.x, dot.y, currentRadius, 0, Math.PI * 2);
118
+ ctx.fillStyle = `rgba(255, 255, 255, ${currentOpacity})`;
119
+ if (dot.blur > 0) ctx.filter = `blur(${dot.blur}px)`;
120
+ ctx.fill();
121
+ ctx.filter = "none";
122
+ });
123
+
124
+ animationFrameId = requestAnimationFrame(draw);
125
+ };
126
+
127
+ const handleResize = () => {
128
+ cancelAnimationFrame(animationFrameId);
129
+ setup();
130
+ draw();
131
+ };
132
+
133
+ setup();
134
+ draw();
135
+ window.addEventListener("resize", handleResize);
136
+
137
+ return () => {
138
+ window.removeEventListener("resize", handleResize);
139
+ cancelAnimationFrame(animationFrameId);
140
+ };
141
+ }, []);
142
+
143
+ // Close dropdown on Escape / click outside
144
+ useEffect(() => {
145
+ if (!isModelDropdownOpen) return;
146
+
147
+ const onKey = (e: KeyboardEvent) => {
148
+ if (e.key === "Escape") setIsModelDropdownOpen(false);
149
+ if (e.key === "ArrowDown") {
150
+ e.preventDefault();
151
+ setActiveIndex((i) => Math.min(MODEL_OPTIONS.length - 1, i + 1));
152
+ }
153
+ if (e.key === "ArrowUp") {
154
+ e.preventDefault();
155
+ setActiveIndex((i) => Math.max(0, i - 1));
156
+ }
157
+ if (e.key === "Enter") {
158
+ e.preventDefault();
159
+ const opt = MODEL_OPTIONS[activeIndex];
160
+ if (opt) {
161
+ handleModelSelect(opt.id);
162
+ setIsModelDropdownOpen(false);
163
+ dropdownBtnRef.current?.focus();
164
+ }
165
+ }
166
+ };
167
+
168
+ const onClick = (e: MouseEvent) => {
169
+ const target = e.target as Node;
170
+ if (
171
+ dropdownRef.current &&
172
+ !dropdownRef.current.contains(target) &&
173
+ !dropdownBtnRef.current?.contains(target)
174
+ ) {
175
+ setIsModelDropdownOpen(false);
176
+ }
177
+ };
178
+
179
+ document.addEventListener("keydown", onKey);
180
+ document.addEventListener("mousedown", onClick);
181
+ return () => {
182
+ document.removeEventListener("keydown", onKey);
183
+ document.removeEventListener("mousedown", onClick);
184
+ };
185
+ }, [
186
+ isModelDropdownOpen,
187
+ activeIndex,
188
+ setIsModelDropdownOpen,
189
+ handleModelSelect,
190
+ ]);
191
+
192
+ // Recompute portal position on open + resize
193
+ const [, forceRerender] = useState(0);
194
+ useEffect(() => {
195
+ const onResize = () => forceRerender((x) => x + 1);
196
+ window.addEventListener("resize", onResize);
197
+ return () => window.removeEventListener("resize", onResize);
198
+ }, []);
199
+
200
+ // Compute portal style based on the whole button group (center + clamp + optional drop-up)
201
+ const portalStyle = useMemo(() => {
202
+ if (typeof window === "undefined") return {};
203
+ const anchor = wrapperRef.current || dropdownBtnRef.current;
204
+ if (!anchor) return {};
205
+
206
+ const rect = anchor.getBoundingClientRect();
207
+
208
+ const margin = 8;
209
+ const minWidth = 320;
210
+ const dropdownWidth = Math.max(rect.width, minWidth);
211
+
212
+ // Center
213
+ let left = Math.round(rect.left + rect.width / 2 - dropdownWidth / 2);
214
+ // Clamp to viewport
215
+ left = Math.min(
216
+ Math.max(margin, left),
217
+ window.innerWidth - dropdownWidth - margin
218
+ );
219
+
220
+ // Flip up if not enough space below
221
+ const spaceBelow = window.innerHeight - rect.bottom;
222
+ const spaceAbove = rect.top;
223
+ const estimatedItemH = 56; // rough item height
224
+ const estimatedPad = 16;
225
+ const estimatedHeight =
226
+ estimatedItemH * Math.min(MODEL_OPTIONS.length, 6) + estimatedPad;
227
+ const dropUp = spaceBelow < estimatedHeight && spaceAbove > spaceBelow;
228
+
229
+ const top = dropUp ? rect.top - estimatedHeight - 8 : rect.bottom + 8;
230
+
231
+ return {
232
+ position: "fixed" as const,
233
+ left: `${left}px`,
234
+ top: `${top}px`,
235
+ width: `${dropdownWidth}px`,
236
+ zIndex: 100,
237
+ };
238
+ }, []);
239
+
240
+ return (
241
+ <div className="relative flex flex-col items-center justify-center min-h-screen bg-gradient-to-br from-gray-900 via-slate-900 to-gray-900 text-white p-6 overflow-hidden">
242
+ {/* Background Canvas */}
243
+ <canvas
244
+ ref={canvasRef}
245
+ className="absolute top-0 left-0 w-full h-full z-0"
246
+ />
247
+
248
+ {/* Vignette Overlay */}
249
+ <div className="absolute top-0 left-0 w-full h-full z-10 bg-[radial-gradient(ellipse_at_center,_rgba(15,23,42,0.1)_0%,_rgba(15,23,42,0.4)_40%,_rgba(15,23,42,0.9)_100%)]" />
250
+
251
+ {/* Grid Overlay */}
252
+ <div className="absolute inset-0 z-5 opacity-[0.02] bg-[linear-gradient(rgba(255,255,255,0.1)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.1)_1px,transparent_1px)] bg-[size:50px_50px]" />
253
+
254
+ {/* Main Content */}
255
+ <div className="relative z-20 max-w-4xl w-full flex flex-col items-center">
256
+ {/* Logos */}
257
+ <div className="flex items-center justify-center mb-8 gap-5">
258
+ <a
259
+ href="https://huggingface.co/docs/transformers.js"
260
+ target="_blank"
261
+ rel="noopener noreferrer"
262
+ title="Transformers.js"
263
+ className="transform transition-all duration-300 hover:scale-105 hover:-translate-y-1"
264
+ >
265
+ <HfLogo className="h-16 md:h-20 text-gray-300 hover:text-white drop-shadow-lg" />
266
+ </a>
267
+ <span className="text-gray-500 text-3xl font-extralight">×</span>
268
+ <a
269
+ href="https://modelcontextprotocol.io/"
270
+ target="_blank"
271
+ rel="noopener noreferrer"
272
+ title="Model Context Protocol"
273
+ className="transform transition-all duration-300 hover:scale-105 hover:-translate-y-1"
274
+ >
275
+ <MCPLogo className="h-16 md:h-20 text-gray-300 hover:text-white drop-shadow-lg" />
276
+ </a>
277
+ </div>
278
+
279
+ {/* Hero */}
280
+ <div className="text-center mb-8 space-y-4">
281
+ <h1 className="text-5xl sm:text-6xl md:text-7xl font-black bg-gradient-to-r from-white via-gray-100 to-gray-300 bg-clip-text text-transparent tracking-tight leading-none">
282
+ WebGPU MCP
283
+ </h1>
284
+ <p className="text-lg sm:text-xl md:text-2xl text-gray-300 font-light leading-relaxed">
285
+ Run WebGPU-based models with tool calling in your browser, powered by the{" "}
286
+ <a
287
+ href="https://modelcontextprotocol.io/"
288
+ target="_blank"
289
+ rel="noopener noreferrer"
290
+ >
291
+ <span className="text-indigo-400 font-medium">
292
+ Model Context Protocol (MCP)
293
+ </span>{" "}
294
+ enabling secure, real-time connections to remote servers.
295
+ </a>
296
+ </p>
297
+ <div className="w-24 h-1 bg-gradient-to-r from-indigo-500 to-purple-500 rounded-full mx-auto" />
298
+ </div>
299
+
300
+ {/* Description Cards */}
301
+ <div className="grid md:grid-cols-2 gap-6 text-gray-400 mb-10">
302
+ <div className="bg-white/5 backdrop-blur-sm rounded-xl p-6 border border-white/10">
303
+ <h3 className="text-white font-semibold mb-3 flex items-center">
304
+ <div className="w-2 h-2 bg-indigo-500 rounded-full mr-3" />
305
+ Model Context Protocol
306
+ </h3>
307
+ <p className="text-sm leading-relaxed">
308
+ Connect seamlessly to remote{" "}
309
+ <a
310
+ href="https://modelcontextprotocol.io/"
311
+ target="_blank"
312
+ rel="noopener noreferrer"
313
+ className="text-indigo-400 hover:underline"
314
+ >
315
+ MCP servers
316
+ </a>{" "}
317
+ using streaming or SSE protocols with support for no-auth, basic
318
+ auth, and OAuth.
319
+ </p>
320
+ </div>
321
+
322
+ <div className="bg-white/5 backdrop-blur-sm rounded-xl p-6 border border-white/10">
323
+ <h3 className="text-white font-semibold mb-3 flex items-center">
324
+ <div className="w-2 h-2 bg-purple-500 rounded-full mr-3" />
325
+ WebGPU Models
326
+ </h3>
327
+ <p className="text-sm leading-relaxed">
328
+ Supports any WebGPU-compatible models from{" "}
329
+ <a
330
+ href="https://huggingface.co/onnx-community"
331
+ target="_blank"
332
+ rel="noopener noreferrer"
333
+ className="text-indigo-400 hover:underline"
334
+ >
335
+ Hugging Face ONNX Community
336
+ </a>{" "}
337
+ that support tool calling, optimized for on-device deployment.
338
+ </p>
339
+ </div>
340
+ </div>
341
+
342
+ <p className="text-gray-400 text-base sm:text-lg mb-10">
343
+ Everything runs entirely in your browser with{" "}
344
+ <a
345
+ href="https://huggingface.co/docs/transformers.js"
346
+ target="_blank"
347
+ rel="noopener noreferrer"
348
+ className="text-indigo-400 hover:underline font-medium"
349
+ >
350
+ Transformers.js
351
+ </a>{" "}
352
+ and ONNX Runtime Web.
353
+ </p>
354
+
355
+ {/* Action */}
356
+ <div className="text-center space-y-6">
357
+ <p className="text-gray-400 text-base sm:text-lg font-medium">
358
+ Select a model to load locally, and connect to a remote MCP server
359
+ to get started.
360
+ </p>
361
+
362
+ <div className="relative">
363
+ <div
364
+ ref={wrapperRef} // anchor for dropdown centering
365
+ className="flex rounded-2xl shadow-2xl overflow-hidden"
366
+ >
367
+ <button
368
+ onClick={isLoading ? undefined : loadSelectedModel}
369
+ disabled={isLoading}
370
+ className={`flex items-center justify-center font-bold transition-all text-lg flex-1 ${
371
+ isLoading
372
+ ? "bg-gray-700 text-gray-400 cursor-not-allowed"
373
+ : "bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500 text-white shadow-lg hover:shadow-xl transform hover:scale-[1.01] active:scale-[0.99]"
374
+ }`}
375
+ aria-live="polite"
376
+ aria-busy={isLoading}
377
+ aria-label={
378
+ isLoading
379
+ ? `Loading ${model?.label ?? "model"} ${progress}%`
380
+ : `Load ${model?.label ?? "model"}`
381
+ }
382
+ >
383
+ <div className="px-8 py-4">
384
+ {isLoading ? (
385
+ <div className="flex items-center">
386
+ <div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
387
+ <span className="ml-3 font-semibold">
388
+ Loading... {progress}%
389
+ </span>
390
+ </div>
391
+ ) : (
392
+ <span className="font-semibold">Load {model?.label}</span>
393
+ )}
394
+ </div>
395
+ </button>
396
+
397
+ <button
398
+ ref={dropdownBtnRef}
399
+ onClick={(e) => {
400
+ if (!isLoading) {
401
+ e.stopPropagation();
402
+ setIsModelDropdownOpen(!isModelDropdownOpen);
403
+ setActiveIndex(
404
+ Math.max(
405
+ 0,
406
+ MODEL_OPTIONS.findIndex((m) => m.id === selectedModelId)
407
+ )
408
+ );
409
+ }
410
+ }}
411
+ onKeyDown={(e) => {
412
+ if (isLoading) return;
413
+ if (
414
+ e.key === " " ||
415
+ e.key === "Enter" ||
416
+ e.key === "ArrowDown"
417
+ ) {
418
+ e.preventDefault();
419
+ if (!isModelDropdownOpen) setIsModelDropdownOpen(true);
420
+ }
421
+ }}
422
+ aria-haspopup="menu"
423
+ aria-expanded={isModelDropdownOpen}
424
+ aria-controls="model-dropdown"
425
+ aria-label="Select model"
426
+ className={`px-4 py-4 border-l border-white/20 transition-all ${
427
+ isLoading
428
+ ? "bg-gray-700 cursor-not-allowed"
429
+ : "bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500 hover:shadow-lg transform hover:scale-[1.01] active:scale-[0.99]"
430
+ }`}
431
+ disabled={isLoading}
432
+ >
433
+ <ChevronDown
434
+ size={20}
435
+ className={`transition-transform duration-200 ${
436
+ isModelDropdownOpen ? "rotate-180" : ""
437
+ }`}
438
+ />
439
+ </button>
440
+ </div>
441
+
442
+ {/* Dropdown (Portal) */}
443
+ {isModelDropdownOpen &&
444
+ typeof document !== "undefined" &&
445
+ ReactDOM.createPortal(
446
+ <div
447
+ id="model-dropdown"
448
+ ref={dropdownRef}
449
+ style={portalStyle}
450
+ role="menu"
451
+ aria-label="Model options"
452
+ className="bg-gray-800/95 border border-gray-600/50 rounded-2xl shadow-2xl overflow-hidden animate-in slide-in-from-top-2 duration-200 dropdown-z30"
453
+ >
454
+ {MODEL_OPTIONS.map((option, index) => {
455
+ const selected = selectedModelId === option.id;
456
+ const isActive = activeIndex === index;
457
+ return (
458
+ <button
459
+ key={option.id}
460
+ role="menuitem"
461
+ aria-checked={selected}
462
+ onMouseEnter={() => setActiveIndex(index)}
463
+ onClick={() => {
464
+ handleModelSelect(option.id);
465
+ setIsModelDropdownOpen(false);
466
+ dropdownBtnRef.current?.focus();
467
+ }}
468
+ className={`w-full px-6 py-4 text-left transition-all duration-200 relative group outline-none ${
469
+ selected
470
+ ? "bg-gradient-to-r from-indigo-600/50 to-purple-600/50 text-white border-l-4 border-indigo-400"
471
+ : "text-gray-200 hover:bg-white/10 hover:text-white"
472
+ } ${index === 0 ? "rounded-t-2xl" : ""} ${
473
+ index === MODEL_OPTIONS.length - 1
474
+ ? "rounded-b-2xl"
475
+ : ""
476
+ } ${isActive && !selected ? "bg-white/5" : ""}`}
477
+ >
478
+ <div className="flex items-center justify-between">
479
+ <div>
480
+ <div className="font-semibold text-lg">
481
+ {option.label}
482
+ </div>
483
+ <div className="text-sm text-gray-400 mt-1">
484
+ {option.size}
485
+ </div>
486
+ </div>
487
+ {selected && (
488
+ <div className="w-2 h-2 bg-indigo-400 rounded-full" />
489
+ )}
490
+ </div>
491
+ {!selected && (
492
+ <div className="absolute inset-0 bg-gradient-to-r from-indigo-500/10 to-purple-500/10 opacity-0 group-hover:opacity-100 transition-opacity rounded-2xl" />
493
+ )}
494
+ </button>
495
+ );
496
+ })}
497
+ </div>,
498
+ document.body
499
+ )}
500
+ </div>
501
+ </div>
502
+
503
+ {/* Error */}
504
+ {error && (
505
+ <div
506
+ role="alert"
507
+ className="bg-red-900/30 backdrop-blur-sm border border-red-500/50 rounded-2xl p-6 mt-8 max-w-md text-center"
508
+ >
509
+ <p className="text-red-200 mb-4 font-medium">Error: {error}</p>
510
+ <button
511
+ onClick={loadSelectedModel}
512
+ className="bg-gradient-to-r from-red-600 to-red-700 hover:from-red-500 hover:to-red-600 px-6 py-3 rounded-xl font-semibold transition-all transform hover:scale-105 active:scale-95 shadow-lg"
513
+ >
514
+ Try Again
515
+ </button>
516
+ </div>
517
+ )}
518
+ </div>
519
+
520
+ {/* Click-away fallback for touch devices */}
521
+ {isModelDropdownOpen && (
522
+ <div
523
+ className="fixed inset-0 z-40 bg-black/20"
524
+ onClick={(e) => {
525
+ const target = e.target as Node;
526
+ if (
527
+ dropdownRef.current &&
528
+ !dropdownRef.current.contains(target) &&
529
+ !dropdownBtnRef.current?.contains(target)
530
+ ) {
531
+ setIsModelDropdownOpen(false);
532
+ }
533
+ }}
534
+ />
535
+ )}
536
+ </div>
537
+ );
538
+ };
src/components/MCPServerManager.tsx ADDED
@@ -0,0 +1,535 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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(
57
+ STORAGE_KEYS.MCP_SERVER_TRANSPORT,
58
+ newServer.transport
59
+ );
60
+
61
+ try {
62
+ await addServer(serverConfig);
63
+ setNewServer({
64
+ name: "",
65
+ url: "",
66
+ enabled: true,
67
+ transport: "streamable-http",
68
+ auth: {
69
+ type: "bearer",
70
+ },
71
+ });
72
+ setShowAddForm(false);
73
+ } catch (error) {
74
+ setNotification({
75
+ message: `Failed to add server: ${
76
+ error instanceof Error ? error.message : "Unknown error"
77
+ }`,
78
+ type: "error",
79
+ });
80
+ setTimeout(() => setNotification(null), DEFAULTS.OAUTH_ERROR_TIMEOUT);
81
+ }
82
+ };
83
+
84
+ const handleTestConnection = async (config: MCPServerConfig) => {
85
+ setTestingConnection(config.id);
86
+ try {
87
+ const success = await testConnection(config);
88
+ if (success) {
89
+ setNotification({
90
+ message: "Connection test successful!",
91
+ type: "success",
92
+ });
93
+ } else {
94
+ setNotification({
95
+ message: "Connection test failed. Please check your configuration.",
96
+ type: "error",
97
+ });
98
+ }
99
+ } catch (error) {
100
+ setNotification({
101
+ message: `Connection test failed: ${error}`,
102
+ type: "error",
103
+ });
104
+ } finally {
105
+ setTestingConnection(null);
106
+ // Auto-hide notification after 3 seconds
107
+ setTimeout(() => setNotification(null), DEFAULTS.NOTIFICATION_TIMEOUT);
108
+ }
109
+ };
110
+
111
+ const handleToggleConnection = async (
112
+ serverId: string,
113
+ isConnected: boolean
114
+ ) => {
115
+ try {
116
+ if (isConnected) {
117
+ await disconnectFromServer(serverId);
118
+ } else {
119
+ await connectToServer(serverId);
120
+ }
121
+ } catch (error) {
122
+ setNotification({
123
+ message: `Failed to toggle connection: ${
124
+ error instanceof Error ? error.message : "Unknown error"
125
+ }`,
126
+ type: "error",
127
+ });
128
+ setTimeout(() => setNotification(null), DEFAULTS.OAUTH_ERROR_TIMEOUT);
129
+ }
130
+ };
131
+
132
+ return (
133
+ <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
134
+ <div className="bg-gray-800 rounded-lg p-6 w-full max-w-4xl max-h-[80vh] overflow-y-auto">
135
+ <div className="flex justify-between items-center mb-6">
136
+ <h2 className="text-2xl font-bold text-white flex items-center gap-2">
137
+ <Server className="text-blue-400" />
138
+ MCP Server Manager
139
+ </h2>
140
+ <button onClick={onClose} className="text-gray-400 hover:text-white">
141
+
142
+ </button>
143
+ </div>
144
+
145
+ {/* Add Server Button */}
146
+ <div className="mb-6">
147
+ <button
148
+ onClick={() => setShowAddForm(!showAddForm)}
149
+ className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center gap-2"
150
+ >
151
+ <Plus size={16} />
152
+ Add MCP Server
153
+ </button>
154
+ </div>
155
+
156
+ {/* Add Server Form */}
157
+ {showAddForm && (
158
+ <div className="bg-gray-700 rounded-lg p-4 mb-6">
159
+ <h3 className="text-lg font-semibold text-white mb-4">
160
+ Add New MCP Server
161
+ </h3>
162
+ <div className="space-y-4">
163
+ <div>
164
+ <label className="block text-sm font-medium text-gray-300 mb-1">
165
+ Server Name
166
+ </label>
167
+ <input
168
+ type="text"
169
+ value={newServer.name}
170
+ onChange={(e) =>
171
+ setNewServer({ ...newServer, name: e.target.value })
172
+ }
173
+ className="w-full bg-gray-600 text-white rounded px-3 py-2"
174
+ placeholder="My MCP Server"
175
+ />
176
+ </div>
177
+
178
+ <div>
179
+ <label className="block text-sm font-medium text-gray-300 mb-1">
180
+ Server URL
181
+ </label>
182
+ <input
183
+ type="url"
184
+ value={newServer.url}
185
+ onChange={(e) =>
186
+ setNewServer({ ...newServer, url: e.target.value })
187
+ }
188
+ className="w-full bg-gray-600 text-white rounded px-3 py-2"
189
+ placeholder="http://localhost:3000/mcp"
190
+ />
191
+ </div>
192
+
193
+ <div>
194
+ <label className="block text-sm font-medium text-gray-300 mb-1">
195
+ Transport
196
+ </label>
197
+ <select
198
+ value={newServer.transport}
199
+ onChange={(e) =>
200
+ setNewServer({
201
+ ...newServer,
202
+ transport: e.target.value as MCPServerConfig["transport"],
203
+ })
204
+ }
205
+ className="w-full bg-gray-600 text-white rounded px-3 py-2"
206
+ >
207
+ <option value="streamable-http">Streamable HTTP</option>
208
+ <option value="sse">Server-Sent Events</option>
209
+ </select>
210
+ </div>
211
+
212
+ <div>
213
+ <label className="block text-sm font-medium text-gray-300 mb-1">
214
+ Authentication
215
+ </label>
216
+ <select
217
+ value={newServer.auth?.type || "none"}
218
+ onChange={(e) => {
219
+ const authType = e.target.value;
220
+ if (authType === "none") {
221
+ setNewServer({ ...newServer, auth: undefined });
222
+ } else {
223
+ setNewServer({
224
+ ...newServer,
225
+ auth: {
226
+ type: authType as "bearer" | "basic" | "oauth",
227
+ ...(authType === "bearer" ? { token: "" } : {}),
228
+ ...(authType === "basic"
229
+ ? { username: "", password: "" }
230
+ : {}),
231
+ ...(authType === "oauth" ? { token: "" } : {}),
232
+ },
233
+ });
234
+ }
235
+ }}
236
+ className="w-full bg-gray-600 text-white rounded px-3 py-2"
237
+ >
238
+ <option value="none">No Authentication</option>
239
+ <option value="bearer">Bearer Token</option>
240
+ <option value="basic">Basic Auth</option>
241
+ <option value="oauth">OAuth Token</option>
242
+ </select>
243
+ </div>
244
+
245
+ {/* Auth-specific fields */}
246
+ {newServer.auth?.type === "bearer" && (
247
+ <div>
248
+ <label className="block text-sm font-medium text-gray-300 mb-1">
249
+ Bearer Token
250
+ </label>
251
+ <input
252
+ type="password"
253
+ value={newServer.auth.token || ""}
254
+ onChange={(e) =>
255
+ setNewServer({
256
+ ...newServer,
257
+ auth: { ...newServer.auth!, token: e.target.value },
258
+ })
259
+ }
260
+ className="w-full bg-gray-600 text-white rounded px-3 py-2"
261
+ placeholder="your-bearer-token"
262
+ />
263
+ </div>
264
+ )}
265
+
266
+ {newServer.auth?.type === "basic" && (
267
+ <>
268
+ <div>
269
+ <label className="block text-sm font-medium text-gray-300 mb-1">
270
+ Username
271
+ </label>
272
+ <input
273
+ type="text"
274
+ value={newServer.auth.username || ""}
275
+ onChange={(e) =>
276
+ setNewServer({
277
+ ...newServer,
278
+ auth: {
279
+ ...newServer.auth!,
280
+ username: e.target.value,
281
+ },
282
+ })
283
+ }
284
+ className="w-full bg-gray-600 text-white rounded px-3 py-2"
285
+ placeholder="username"
286
+ />
287
+ </div>
288
+ <div>
289
+ <label className="block text-sm font-medium text-gray-300 mb-1">
290
+ Password
291
+ </label>
292
+ <input
293
+ type="password"
294
+ value={newServer.auth.password || ""}
295
+ onChange={(e) =>
296
+ setNewServer({
297
+ ...newServer,
298
+ auth: {
299
+ ...newServer.auth!,
300
+ password: e.target.value,
301
+ },
302
+ })
303
+ }
304
+ className="w-full bg-gray-600 text-white rounded px-3 py-2"
305
+ placeholder="password"
306
+ />
307
+ </div>
308
+ </>
309
+ )}
310
+
311
+ {newServer.auth?.type === "oauth" && (
312
+ <div>
313
+ <label className="block text-sm font-medium text-gray-300 mb-1">
314
+ OAuth Authorization
315
+ </label>
316
+ <button
317
+ className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded mb-2"
318
+ type="button"
319
+ onClick={async () => {
320
+ try {
321
+ // Persist name and transport for OAuthCallback
322
+ localStorage.setItem(
323
+ STORAGE_KEYS.MCP_SERVER_NAME,
324
+ newServer.name
325
+ );
326
+ localStorage.setItem(
327
+ STORAGE_KEYS.MCP_SERVER_TRANSPORT,
328
+ newServer.transport
329
+ );
330
+ const endpoints = await discoverOAuthEndpoints(
331
+ newServer.url
332
+ );
333
+
334
+ if (!endpoints.clientId || !endpoints.redirectUri) {
335
+ throw new Error(
336
+ "Missing required OAuth configuration (clientId or redirectUri)"
337
+ );
338
+ }
339
+
340
+ startOAuthFlow({
341
+ authorizationEndpoint:
342
+ endpoints.authorizationEndpoint,
343
+ clientId: endpoints.clientId as string,
344
+ redirectUri: endpoints.redirectUri as string,
345
+ scopes: (endpoints.scopes || []) as string[],
346
+ });
347
+ } catch (err) {
348
+ setNotification({
349
+ message:
350
+ "OAuth discovery failed: " +
351
+ (err instanceof Error ? err.message : String(err)),
352
+ type: "error",
353
+ });
354
+ setTimeout(
355
+ () => setNotification(null),
356
+ DEFAULTS.OAUTH_ERROR_TIMEOUT
357
+ );
358
+ }
359
+ }}
360
+ >
361
+ Connect with OAuth
362
+ </button>
363
+ <p className="text-xs text-gray-400">
364
+ You will be redirected to authorize this app with the MCP
365
+ server.
366
+ </p>
367
+ </div>
368
+ )}
369
+
370
+ <div className="flex items-center gap-2">
371
+ <input
372
+ type="checkbox"
373
+ id="enabled"
374
+ checked={newServer.enabled}
375
+ onChange={(e) =>
376
+ setNewServer({ ...newServer, enabled: e.target.checked })
377
+ }
378
+ className="rounded"
379
+ />
380
+ <label htmlFor="enabled" className="text-sm text-gray-300">
381
+ Auto-connect when added
382
+ </label>
383
+ </div>
384
+
385
+ <div className="flex gap-2">
386
+ <button
387
+ onClick={handleAddServer}
388
+ className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded"
389
+ >
390
+ Add Server
391
+ </button>
392
+ <button
393
+ onClick={() => setShowAddForm(false)}
394
+ className="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded"
395
+ >
396
+ Cancel
397
+ </button>
398
+ </div>
399
+ </div>
400
+ </div>
401
+ )}
402
+
403
+ {/* Server List */}
404
+ <div className="space-y-4">
405
+ <h3 className="text-lg font-semibold text-white">
406
+ Configured Servers
407
+ </h3>
408
+
409
+ {Object.values(mcpState.servers).length === 0 ? (
410
+ <div className="text-gray-400 text-center py-8">
411
+ No MCP servers configured. Add one to get started!
412
+ </div>
413
+ ) : (
414
+ Object.values(mcpState.servers).map((connection) => (
415
+ <div
416
+ key={connection.config.id}
417
+ className="bg-gray-700 rounded-lg p-4"
418
+ >
419
+ <div className="flex items-center justify-between">
420
+ <div className="flex items-center gap-3">
421
+ <div
422
+ className={`w-3 h-3 rounded-full ${
423
+ connection.isConnected ? "bg-green-400" : "bg-red-400"
424
+ }`}
425
+ />
426
+ <div>
427
+ <h4 className="text-white font-medium">
428
+ {connection.config.name}
429
+ </h4>
430
+ <p className="text-gray-400 text-sm">
431
+ {connection.config.url}
432
+ </p>
433
+ <p className="text-gray-500 text-xs">
434
+ Transport: {connection.config.transport}
435
+ {connection.config.auth &&
436
+ ` • Auth: ${connection.config.auth.type}`}
437
+ {connection.isConnected &&
438
+ ` • ${connection.tools.length} tools available`}
439
+ </p>
440
+ </div>
441
+ </div>
442
+
443
+ <div className="flex items-center gap-2">
444
+ {/* Test Connection */}
445
+ <button
446
+ onClick={() => handleTestConnection(connection.config)}
447
+ disabled={testingConnection === connection.config.id}
448
+ className="p-2 text-yellow-400 hover:text-yellow-300 disabled:opacity-50"
449
+ title="Test Connection"
450
+ >
451
+ <TestTube size={16} />
452
+ </button>
453
+
454
+ {/* Connect/Disconnect */}
455
+ <button
456
+ onClick={() =>
457
+ handleToggleConnection(
458
+ connection.config.id,
459
+ connection.isConnected
460
+ )
461
+ }
462
+ className={`p-2 ${
463
+ connection.isConnected
464
+ ? "text-green-400 hover:text-green-300"
465
+ : "text-gray-400 hover:text-gray-300"
466
+ }`}
467
+ title={connection.isConnected ? "Disconnect" : "Connect"}
468
+ >
469
+ {connection.isConnected ? (
470
+ <Wifi size={16} />
471
+ ) : (
472
+ <WifiOff size={16} />
473
+ )}
474
+ </button>
475
+
476
+ {/* Remove Server */}
477
+ <button
478
+ onClick={() => removeServer(connection.config.id)}
479
+ className="p-2 text-red-400 hover:text-red-300"
480
+ title="Remove Server"
481
+ >
482
+ <Trash2 size={16} />
483
+ </button>
484
+ </div>
485
+ </div>
486
+
487
+ {connection.lastError && (
488
+ <div className="mt-2 text-red-400 text-sm">
489
+ Error: {connection.lastError}
490
+ </div>
491
+ )}
492
+
493
+ {connection.isConnected && connection.tools.length > 0 && (
494
+ <div className="mt-3">
495
+ <details className="text-sm">
496
+ <summary className="text-gray-300 cursor-pointer">
497
+ Available Tools ({connection.tools.length})
498
+ </summary>
499
+ <div className="mt-2 space-y-1">
500
+ {connection.tools.map((tool) => (
501
+ <div key={tool.name} className="text-gray-400 pl-4">
502
+ • {tool.name} -{" "}
503
+ {tool.description || "No description"}
504
+ </div>
505
+ ))}
506
+ </div>
507
+ </details>
508
+ </div>
509
+ )}
510
+ </div>
511
+ ))
512
+ )}
513
+ </div>
514
+
515
+ {mcpState.error && (
516
+ <div className="mt-4 p-4 bg-red-900 border border-red-700 rounded-lg text-red-200">
517
+ <strong>Error:</strong> {mcpState.error}
518
+ </div>
519
+ )}
520
+
521
+ {notification && (
522
+ <div
523
+ className={`mt-4 p-4 border rounded-lg ${
524
+ notification.type === "success"
525
+ ? "bg-green-900 border-green-700 text-green-200"
526
+ : "bg-red-900 border-red-700 text-red-200"
527
+ }`}
528
+ >
529
+ {notification.message}
530
+ </div>
531
+ )}
532
+ </div>
533
+ </div>
534
+ );
535
+ };
src/components/OAuthCallback.tsx ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState } from "react";
2
+ import { useNavigate } from "react-router-dom";
3
+ import { exchangeCodeForToken } from "../services/oauth";
4
+ import { secureStorage } from "../utils/storage";
5
+ import type { MCPServerConfig } from "../types/mcp";
6
+ import { STORAGE_KEYS, DEFAULTS } from "../config/constants";
7
+
8
+ interface OAuthTokens {
9
+ access_token: string;
10
+ refresh_token?: string;
11
+ expires_in?: number;
12
+ token_type?: string;
13
+ [key: string]: string | number | undefined;
14
+ }
15
+
16
+ interface OAuthCallbackProps {
17
+ serverUrl: string;
18
+ onSuccess?: (tokens: OAuthTokens) => void;
19
+ onError?: (error: Error) => void;
20
+ }
21
+
22
+ const OAuthCallback: React.FC<OAuthCallbackProps> = ({
23
+ serverUrl,
24
+ onSuccess,
25
+ onError,
26
+ }) => {
27
+ const [status, setStatus] = useState<string>("Authorizing...");
28
+ const navigate = useNavigate(); // Add this hook
29
+
30
+ useEffect(() => {
31
+ // Parse parameters from URL search params (OAuth providers send code in query string)
32
+ const parseHashParams = () => {
33
+ return new URLSearchParams(window.location.search);
34
+ };
35
+
36
+ const params = parseHashParams();
37
+ const code = params.get("code");
38
+ const state = params.get("state");
39
+ const error = params.get("error");
40
+
41
+ // Verify state parameter for CSRF protection
42
+ const savedState = localStorage.getItem('oauth_state');
43
+ if (state !== savedState) {
44
+ setStatus("Invalid state parameter. Possible CSRF attack.");
45
+ if (onError) onError(new Error("Invalid state parameter"));
46
+ return;
47
+ }
48
+
49
+ // Check for OAuth errors
50
+ if (error) {
51
+ const errorDescription = params.get("error_description") || error;
52
+ setStatus(`OAuth error: ${errorDescription}`);
53
+ if (onError) onError(new Error(errorDescription));
54
+ return;
55
+ }
56
+
57
+ // Always persist MCP server URL for robustness
58
+ localStorage.setItem(STORAGE_KEYS.OAUTH_MCP_SERVER_URL, serverUrl);
59
+
60
+ if (code) {
61
+ exchangeCodeForToken({
62
+ serverUrl,
63
+ code,
64
+ redirectUri: window.location.origin + "/#" + DEFAULTS.OAUTH_REDIRECT_PATH, // Add hash
65
+ })
66
+ .then(async (tokens) => {
67
+ await secureStorage.setItem(STORAGE_KEYS.OAUTH_ACCESS_TOKEN, tokens.access_token);
68
+
69
+ // Add MCP server to MCPClientService for UI
70
+ const mcpServerUrl = localStorage.getItem(STORAGE_KEYS.OAUTH_MCP_SERVER_URL);
71
+ if (mcpServerUrl) {
72
+ const serverName =
73
+ localStorage.getItem(STORAGE_KEYS.MCP_SERVER_NAME) || mcpServerUrl;
74
+ const serverTransport =
75
+ (localStorage.getItem(STORAGE_KEYS.MCP_SERVER_TRANSPORT) as MCPServerConfig['transport']) || DEFAULTS.MCP_TRANSPORT;
76
+
77
+ const serverConfig = {
78
+ id: `server_${Date.now()}`,
79
+ name: serverName,
80
+ url: mcpServerUrl,
81
+ enabled: true,
82
+ transport: serverTransport,
83
+ auth: {
84
+ type: "bearer" as const,
85
+ token: tokens.access_token,
86
+ },
87
+ };
88
+
89
+ let servers: MCPServerConfig[] = [];
90
+ try {
91
+ const stored = localStorage.getItem(STORAGE_KEYS.MCP_SERVERS);
92
+ if (stored) servers = JSON.parse(stored);
93
+ } catch {}
94
+
95
+ const exists = servers.some((s: MCPServerConfig) => s.url === mcpServerUrl);
96
+ if (!exists) {
97
+ servers.push(serverConfig);
98
+ localStorage.setItem(STORAGE_KEYS.MCP_SERVERS, JSON.stringify(servers));
99
+ }
100
+
101
+ // Clear temp values
102
+ localStorage.removeItem(STORAGE_KEYS.MCP_SERVER_NAME);
103
+ localStorage.removeItem(STORAGE_KEYS.MCP_SERVER_TRANSPORT);
104
+ localStorage.removeItem(STORAGE_KEYS.OAUTH_MCP_SERVER_URL);
105
+ }
106
+
107
+ // Clear OAuth state
108
+ localStorage.removeItem('oauth_state');
109
+
110
+ setStatus("Authorization successful! Redirecting...");
111
+ if (onSuccess) onSuccess(tokens);
112
+
113
+ // Use React Router navigation instead of window.location.replace
114
+ setTimeout(() => {
115
+ navigate("/", { replace: true });
116
+ }, 1000);
117
+ })
118
+ .catch((err) => {
119
+ setStatus("OAuth token exchange failed: " + err.message);
120
+ if (onError) onError(err);
121
+ // Clear OAuth state on error
122
+ localStorage.removeItem('oauth_state');
123
+ });
124
+ } else {
125
+ setStatus("Missing authorization code in callback URL.");
126
+ if (onError) onError(new Error("Missing authorization code"));
127
+ }
128
+ }, [serverUrl, onSuccess, onError, navigate]);
129
+
130
+ return (
131
+ <div className="flex items-center justify-center min-h-screen">
132
+ <div className="text-center">
133
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div>
134
+ <p>{status}</p>
135
+ </div>
136
+ </div>
137
+ );
138
+ };
139
+
140
+ export default OAuthCallback;
src/components/ResultBlock.tsx ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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/ToolCallIndicator.tsx ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type React from "react";
2
+ import { extractToolCallContent } from "../utils";
3
+
4
+ const ToolCallIndicator: React.FC<{
5
+ content: string;
6
+ isRunning: boolean;
7
+ hasError: boolean;
8
+ }> = ({ content, isRunning, hasError }) => (
9
+ <div
10
+ className={`transition-all duration-500 ease-in-out rounded-lg p-4 ${
11
+ isRunning
12
+ ? "bg-gradient-to-r from-yellow-900/30 to-orange-900/30 border border-yellow-600/50"
13
+ : hasError
14
+ ? "bg-gradient-to-r from-red-900/30 to-rose-900/30 border border-red-600/50"
15
+ : "bg-gradient-to-r from-green-900/30 to-emerald-900/30 border border-green-600/50"
16
+ }`}
17
+ >
18
+ <div className="flex items-start space-x-3">
19
+ <div className="flex-shrink-0">
20
+ <div className="relative w-6 h-6">
21
+ {/* Spinner for running */}
22
+ <div
23
+ className={`absolute inset-0 flex items-center justify-center transition-opacity duration-500 ${
24
+ isRunning ? "opacity-100" : "opacity-0 pointer-events-none"
25
+ }`}
26
+ >
27
+ <div className="w-6 h-6 bg-green-400/0 border-2 border-yellow-400 border-t-transparent rounded-full animate-spin"></div>
28
+ </div>
29
+
30
+ {/* Cross for error */}
31
+ <div
32
+ className={`absolute inset-0 flex items-center justify-center transition-opacity duration-500 ${
33
+ hasError ? "opacity-100" : "opacity-0 pointer-events-none"
34
+ }`}
35
+ >
36
+ <div className="w-6 h-6 bg-red-400/100 rounded-full flex items-center justify-center transition-colors duration-500 ease-in-out">
37
+ <span className="text-xs text-gray-900 font-bold">✗</span>
38
+ </div>
39
+ </div>
40
+
41
+ {/* Tick for success */}
42
+ <div
43
+ className={`absolute inset-0 flex items-center justify-center transition-opacity duration-500 ${
44
+ !isRunning && !hasError
45
+ ? "opacity-100"
46
+ : "opacity-0 pointer-events-none"
47
+ }`}
48
+ >
49
+ <div className="w-6 h-6 bg-green-400/100 rounded-full flex items-center justify-center transition-colors duration-500 ease-in-out">
50
+ <span className="text-xs text-gray-900 font-bold">✓</span>
51
+ </div>
52
+ </div>
53
+ </div>
54
+ </div>
55
+ <div className="flex-grow min-w-0">
56
+ <div className="flex items-center space-x-2 mb-2">
57
+ <span
58
+ className={`font-semibold text-sm transition-colors duration-500 ease-in-out ${
59
+ isRunning
60
+ ? "text-yellow-400"
61
+ : hasError
62
+ ? "text-red-400"
63
+ : "text-green-400"
64
+ }`}
65
+ >
66
+ 🔧 Tool Call
67
+ </span>
68
+ {isRunning && (
69
+ <span className="text-yellow-300 text-xs animate-pulse">
70
+ Running...
71
+ </span>
72
+ )}
73
+ </div>
74
+ <div className="bg-gray-800/50 rounded p-2 mb-2">
75
+ <code className="text-xs text-gray-300 font-mono break-all">
76
+ {extractToolCallContent(content) ?? "..."}
77
+ </code>
78
+ </div>
79
+ <p
80
+ className={`text-xs transition-colors duration-500 ease-in-out ${
81
+ isRunning
82
+ ? "text-yellow-200"
83
+ : hasError
84
+ ? "text-red-200"
85
+ : "text-green-200"
86
+ }`}
87
+ >
88
+ {isRunning
89
+ ? "Executing tool call..."
90
+ : hasError
91
+ ? "Tool call failed"
92
+ : "Tool call completed"}
93
+ </p>
94
+ </div>
95
+ </div>
96
+ </div>
97
+ );
98
+ export default ToolCallIndicator;
src/components/ToolItem.tsx ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Editor from "@monaco-editor/react";
2
+ import { ChevronUp, ChevronDown, Trash2, Power } from "lucide-react";
3
+ import { useMemo } from "react";
4
+
5
+ import { extractFunctionAndRenderer, generateSchemaFromCode } from "../utils";
6
+
7
+ export interface Tool {
8
+ id: number;
9
+ name: string;
10
+ code: string;
11
+ enabled: boolean;
12
+ isCollapsed?: boolean;
13
+ renderer?: string;
14
+ }
15
+
16
+ interface ToolItemProps {
17
+ tool: Tool;
18
+ onToggleEnabled: () => void;
19
+ onToggleCollapsed: () => void;
20
+ onExpand: () => void;
21
+ onDelete: () => void;
22
+ onCodeChange: (newCode: string) => void;
23
+ }
24
+
25
+ const ToolItem: React.FC<ToolItemProps> = ({
26
+ tool,
27
+ onToggleEnabled,
28
+ onToggleCollapsed,
29
+ onDelete,
30
+ onCodeChange,
31
+ }) => {
32
+ const { functionCode } = extractFunctionAndRenderer(tool.code);
33
+ const schema = useMemo(
34
+ () => generateSchemaFromCode(functionCode),
35
+ [functionCode],
36
+ );
37
+
38
+ return (
39
+ <div
40
+ className={`bg-gray-700 rounded-lg p-4 transition-all ${!tool.enabled ? "opacity-50 grayscale" : ""}`}
41
+ >
42
+ <div
43
+ className="flex justify-between items-center cursor-pointer"
44
+ onClick={onToggleCollapsed}
45
+ >
46
+ <div>
47
+ <h3 className="text-lg font-bold text-teal-300 font-mono">
48
+ {schema.name}
49
+ </h3>
50
+ <div className="text-xs text-gray-300 mt-1">{schema.description}</div>
51
+ </div>
52
+ <div className="flex items-center space-x-3">
53
+ <button
54
+ onClick={(e) => {
55
+ e.stopPropagation();
56
+ onToggleEnabled();
57
+ }}
58
+ className={`p-1 rounded-full ${tool.enabled ? "text-green-400 hover:bg-green-900" : "text-red-400 hover:bg-red-900"}`}
59
+ >
60
+ <Power size={18} />
61
+ </button>
62
+ <button
63
+ onClick={(e) => {
64
+ e.stopPropagation();
65
+ onDelete();
66
+ }}
67
+ className="p-2 text-gray-400 hover:text-red-500 hover:bg-gray-600 rounded-lg"
68
+ >
69
+ <Trash2 size={18} />
70
+ </button>
71
+ <button
72
+ onClick={(e) => {
73
+ e.stopPropagation();
74
+ onToggleCollapsed();
75
+ }}
76
+ className="p-2 text-gray-400 hover:text-white"
77
+ >
78
+ {tool.isCollapsed ? (
79
+ <ChevronDown size={20} />
80
+ ) : (
81
+ <ChevronUp size={20} />
82
+ )}
83
+ </button>
84
+ </div>
85
+ </div>
86
+ {!tool.isCollapsed && (
87
+ <div className="mt-4 grid grid-cols-1 md:grid-cols-3 gap-4">
88
+ <div className="md:col-span-2">
89
+ <label className="text-sm font-bold text-gray-400">
90
+ Implementation & Renderer
91
+ </label>
92
+ <div
93
+ className="mt-1 rounded-md overflow-visible border border-gray-600"
94
+ style={{ overflow: "visible" }}
95
+ >
96
+ <Editor
97
+ height="300px"
98
+ language="javascript"
99
+ theme="vs-dark"
100
+ value={tool.code}
101
+ onChange={(value) => onCodeChange(value || "")}
102
+ options={{
103
+ minimap: { enabled: false },
104
+ scrollbar: { verticalScrollbarSize: 10 },
105
+ fontSize: 14,
106
+ lineDecorationsWidth: 0,
107
+ lineNumbersMinChars: 3,
108
+ scrollBeyondLastLine: false,
109
+ }}
110
+ />
111
+ </div>
112
+ </div>
113
+ <div className="flex flex-col">
114
+ <label className="text-sm font-bold text-gray-400">
115
+ Generated Schema
116
+ </label>
117
+ <div className="mt-1 rounded-md flex-grow overflow-visible border border-gray-600">
118
+ <Editor
119
+ height="300px"
120
+ language="json"
121
+ theme="vs-dark"
122
+ value={JSON.stringify(schema, null, 2)}
123
+ options={{
124
+ readOnly: true,
125
+ minimap: { enabled: false },
126
+ scrollbar: { verticalScrollbarSize: 10 },
127
+ lineNumbers: "off",
128
+ glyphMargin: false,
129
+ folding: false,
130
+ lineDecorationsWidth: 0,
131
+ lineNumbersMinChars: 0,
132
+ scrollBeyondLastLine: false,
133
+ fontSize: 12,
134
+ }}
135
+ />
136
+ </div>
137
+ </div>
138
+ </div>
139
+ )}
140
+ </div>
141
+ );
142
+ };
143
+
144
+ export default ToolItem;
src/components/ToolResultRenderer.tsx ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ }
14
+
15
+ try {
16
+ const exportMatch = rendererCode.match(/export\s+default\s+(.*)/s);
17
+ if (!exportMatch) {
18
+ throw new Error("Invalid renderer format - no export default found");
19
+ }
20
+
21
+ const componentCode = exportMatch[1].trim();
22
+ const componentFunction = new Function(
23
+ "React",
24
+ "input",
25
+ "output",
26
+ `
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 (
37
+ <ResultBlock
38
+ error={error instanceof Error ? error.message : "Unknown error"}
39
+ result={result}
40
+ />
41
+ );
42
+ }
43
+ };
44
+ export default ToolResultRenderer;
src/components/icons/HfLogo.tsx ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type React from "react";
2
+
3
+ export default (props: React.SVGProps<SVGSVGElement>) => (
4
+ <svg
5
+ {...props}
6
+ xmlns="http://www.w3.org/2000/svg"
7
+ viewBox="0 0 24 24"
8
+ fill="currentColor"
9
+ >
10
+ <path
11
+ d="M2.25 11.535c0-3.407 1.847-6.554 4.844-8.258a9.822 9.822 0 019.687 0c2.997 1.704 4.844 4.851 4.844 8.258 0 5.266-4.337 9.535-9.687 9.535S2.25 16.8 2.25 11.535z"
12
+ fill="#FF9D0B"
13
+ ></path>
14
+ <path
15
+ d="M11.938 20.086c4.797 0 8.687-3.829 8.687-8.551 0-4.722-3.89-8.55-8.687-8.55-4.798 0-8.688 3.828-8.688 8.55 0 4.722 3.89 8.55 8.688 8.55z"
16
+ fill="#FFD21E"
17
+ ></path>
18
+ <path
19
+ d="M11.875 15.113c2.457 0 3.25-2.156 3.25-3.263 0-.576-.393-.394-1.023-.089-.582.283-1.365.675-2.224.675-1.798 0-3.25-1.693-3.25-.586 0 1.107.79 3.263 3.25 3.263h-.003z"
20
+ fill="#FF323D"
21
+ ></path>
22
+ <path
23
+ d="M14.76 9.21c.32.108.445.753.767.585.447-.233.707-.708.659-1.204a1.235 1.235 0 00-.879-1.059 1.262 1.262 0 00-1.33.394c-.322.384-.377.92-.14 1.36.153.283.638-.177.925-.079l-.002.003zm-5.887 0c-.32.108-.448.753-.768.585a1.226 1.226 0 01-.658-1.204c.048-.495.395-.913.878-1.059a1.262 1.262 0 011.33.394c.322.384.377.92.14 1.36-.152.283-.64-.177-.925-.079l.003.003zm1.12 5.34a2.166 2.166 0 011.325-1.106c.07-.02.144.06.219.171l.192.306c.069.1.139.175.209.175.074 0 .15-.074.223-.172l.205-.302c.08-.11.157-.188.234-.165.537.168.986.536 1.25 1.026.932-.724 1.275-1.905 1.275-2.633 0-.508-.306-.426-.81-.19l-.616.296c-.52.24-1.148.48-1.824.48-.676 0-1.302-.24-1.823-.48l-.589-.283c-.52-.248-.838-.342-.838.177 0 .703.32 1.831 1.187 2.56l.18.14z"
24
+ fill="#3A3B45"
25
+ ></path>
26
+ <path
27
+ d="M17.812 10.366a.806.806 0 00.813-.8c0-.441-.364-.8-.813-.8a.806.806 0 00-.812.8c0 .442.364.8.812.8zm-11.624 0a.806.806 0 00.812-.8c0-.441-.364-.8-.812-.8a.806.806 0 00-.813.8c0 .442.364.8.813.8zM4.515 13.073c-.405 0-.765.162-1.017.46a1.455 1.455 0 00-.333.925 1.801 1.801 0 00-.485-.074c-.387 0-.737.146-.985.409a1.41 1.41 0 00-.2 1.722 1.302 1.302 0 00-.447.694c-.06.222-.12.69.2 1.166a1.267 1.267 0 00-.093 1.236c.238.533.81.958 1.89 1.405l.24.096c.768.3 1.473.492 1.478.494.89.243 1.808.375 2.732.394 1.465 0 2.513-.443 3.115-1.314.93-1.342.842-2.575-.274-3.763l-.151-.154c-.692-.684-1.155-1.69-1.25-1.912-.195-.655-.71-1.383-1.562-1.383-.46.007-.889.233-1.15.605-.25-.31-.495-.553-.715-.694a1.87 1.87 0 00-.993-.312zm14.97 0c.405 0 .767.162 1.017.46.216.262.333.588.333.925.158-.047.322-.071.487-.074.388 0 .738.146.985.409a1.41 1.41 0 01.2 1.722c.22.178.377.422.445.694.06.222.12.69-.2 1.166.244.37.279.836.093 1.236-.238.533-.81.958-1.889 1.405l-.239.096c-.77.3-1.475.492-1.48.494-.89.243-1.808.375-2.732.394-1.465 0-2.513-.443-3.115-1.314-.93-1.342-.842-2.575.274-3.763l.151-.154c.695-.684 1.157-1.69 1.252-1.912.195-.655.708-1.383 1.56-1.383.46.007.889.233 1.15.605.25-.31.495-.553.718-.694.244-.162.523-.265.814-.3l.176-.012z"
28
+ fill="#FF9D0B"
29
+ ></path>
30
+ <path
31
+ d="M9.785 20.132c.688-.994.638-1.74-.305-2.667-.945-.928-1.495-2.288-1.495-2.288s-.205-.788-.672-.714c-.468.074-.81 1.25.17 1.971.977.721-.195 1.21-.573.534-.375-.677-1.405-2.416-1.94-2.751-.532-.332-.907-.148-.782.541.125.687 2.357 2.35 2.14 2.707-.218.362-.983-.42-.983-.42S2.953 14.9 2.43 15.46c-.52.558.398 1.026 1.7 1.803 1.308.778 1.41.985 1.225 1.28-.187.295-3.07-2.1-3.34-1.083-.27 1.011 2.943 1.304 2.745 2.006-.2.7-2.265-1.324-2.685-.537-.425.79 2.913 1.718 2.94 1.725 1.075.276 3.813.859 4.77-.522zm4.432 0c-.687-.994-.64-1.74.305-2.667.943-.928 1.493-2.288 1.493-2.288s.205-.788.675-.714c.465.074.807 1.25-.17 1.971-.98.721.195 1.21.57.534.377-.677 1.407-2.416 1.94-2.751.532-.332.91-.148.782.541-.125.687-2.355 2.35-2.137 2.707.215.362.98-.42.98-.42S21.05 14.9 21.57 15.46c.52.558-.395 1.026-1.7 1.803-1.308.778-1.408.985-1.225 1.28.187.295 3.07-2.1 3.34-1.083.27 1.011-2.94 1.304-2.743 2.006.2.7 2.263-1.324 2.685-.537.423.79-2.912 1.718-2.94 1.725-1.077.276-3.815.859-4.77-.522z"
32
+ fill="#FFD21E"
33
+ ></path>
34
+ </svg>
35
+ );
src/components/icons/LiquidAILogo.tsx ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type React from "react";
2
+
3
+ export default (props: React.SVGProps<SVGSVGElement>) => (
4
+ <svg
5
+ {...props}
6
+ xmlns="http://www.w3.org/2000/svg"
7
+ viewBox="0 0 24 24"
8
+ fill="currentColor"
9
+ >
10
+ <path d="M12.028 8.546l-.008.005 3.03 5.25a3.94 3.94 0 01.643 2.162c0 .754-.212 1.46-.58 2.062l6.173-1.991L11.63 0 9.304 3.872l2.724 4.674zM6.837 24l4.85-4.053h-.013c-2.219 0-4.017-1.784-4.017-3.984 0-.794.235-1.534.64-2.156l2.865-4.976-2.381-4.087L2 16.034 6.83 24h.007zM13.737 19.382h-.001L8.222 24h8.182l4.148-6.769-6.815 2.151z"></path>
11
+ </svg>
12
+ );
src/components/icons/MCPLogo.tsx ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+
3
+ const MCPLogo = ({
4
+ className = "",
5
+ ...props
6
+ }: React.SVGProps<SVGSVGElement>) => (
7
+ <svg
8
+ viewBox="0 0 180 180"
9
+ fill="none"
10
+ xmlns="http://www.w3.org/2000/svg"
11
+ className={className}
12
+ {...props}
13
+ >
14
+ <path
15
+ d="M23.5996 85.2532L86.2021 22.6507C94.8457 14.0071 108.86 14.0071 117.503 22.6507C126.147 31.2942 126.147 45.3083 117.503 53.9519L70.2254 101.23"
16
+ stroke="currentColor"
17
+ strokeWidth="11.0667"
18
+ strokeLinecap="round"
19
+ />
20
+ <path
21
+ d="M70.8789 100.578L117.504 53.952C126.148 45.3083 140.163 45.3083 148.806 53.952L149.132 54.278C157.776 62.9216 157.776 76.9357 149.132 85.5792L92.5139 142.198C89.6327 145.079 89.6327 149.75 92.5139 152.631L104.14 164.257"
22
+ stroke="currentColor"
23
+ strokeWidth="11.0667"
24
+ strokeLinecap="round"
25
+ />
26
+ <path
27
+ d="M101.853 38.3013L55.553 84.6011C46.9094 93.2447 46.9094 107.258 55.553 115.902C64.1966 124.546 78.2106 124.546 86.8543 115.902L133.154 69.6025"
28
+ stroke="currentColor"
29
+ strokeWidth="11.0667"
30
+ strokeLinecap="round"
31
+ />
32
+ </svg>
33
+ );
34
+
35
+ export default MCPLogo;
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/constants/db.ts ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ export const DB_NAME = "tool-caller-db";
2
+ export const STORE_NAME = "tools";
3
+ export const SETTINGS_STORE_NAME = "settings";
src/constants/examples.ts ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export interface Example {
2
+ icon: string;
3
+ displayText: string;
4
+ messageText: string;
5
+ }
6
+
7
+ export const DEFAULT_EXAMPLES: Example[] = [
8
+ {
9
+ icon: "🌍",
10
+ displayText: "Where am I and what time is it?",
11
+ messageText: "Where am I and what time is it?",
12
+ },
13
+ {
14
+ icon: "👋",
15
+ displayText: "Say hello",
16
+ messageText: "Say hello",
17
+ },
18
+ {
19
+ icon: "🔢",
20
+ displayText: "Solve a math problem",
21
+ messageText: "What is 123 plus 15% of 200 all divided by 7?",
22
+ },
23
+ {
24
+ icon: "😴",
25
+ displayText: "Sleep for 3 seconds",
26
+ messageText: "Sleep for 3 seconds",
27
+ },
28
+ {
29
+ icon: "🎲",
30
+ displayText: "Generate a random number",
31
+ messageText: "Generate a random number between 1 and 100.",
32
+ },
33
+ {
34
+ icon: "📹",
35
+ displayText: "Play a video",
36
+ messageText:
37
+ 'Open the following webpage: "https://www.youtube.com/embed/dQw4w9WgXcQ?autoplay=1".',
38
+ },
39
+ ];
src/constants/models.ts ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ 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
+ ];
src/constants/systemPrompt.ts ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const DEFAULT_SYSTEM_PROMPT = [
2
+ "You are an AI assistant with access to a set of tools.",
3
+ "When a user asks a question, determine if a tool should be called to help answer.",
4
+ "If a tool is needed, respond with a tool call using the following format: ",
5
+ "<|tool_call_start|>[tool_function_call_1, tool_function_call_2, ...]<|tool_call_end|>.",
6
+ 'Each tool function call should use Python-like syntax, e.g., speak("Hello"), random_number(min=1, max=10).',
7
+ "If no tool is needed, you should answer the user directly without calling any tools.",
8
+ "Always use the most relevant tool(s) for the user's request.",
9
+ "If a tool returns an error, explain the error to the user.",
10
+ "Be concise and helpful.",
11
+ ].join(" ");
src/hooks/useLLM.ts ADDED
@@ -0,0 +1,234 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect, useRef, useCallback } from "react";
2
+ import {
3
+ AutoModelForCausalLM,
4
+ AutoTokenizer,
5
+ TextStreamer,
6
+ } from "@huggingface/transformers";
7
+
8
+ interface LLMState {
9
+ isLoading: boolean;
10
+ isReady: boolean;
11
+ error: string | null;
12
+ progress: number;
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;
24
+ };
25
+ } = {};
26
+
27
+ export const useLLM = (modelId?: string) => {
28
+ const [state, setState] = useState<LLMState>({
29
+ isLoading: false,
30
+ isReady: false,
31
+ error: null,
32
+ progress: 0,
33
+ });
34
+
35
+ const instanceRef = useRef<LLMInstance | null>(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] = {
50
+ instance: null,
51
+ loadingPromise: null,
52
+ };
53
+ }
54
+
55
+ const cache = moduleCache[modelId];
56
+
57
+ const existingInstance = instanceRef.current || cache.instance;
58
+ if (existingInstance) {
59
+ instanceRef.current = existingInstance;
60
+ cache.instance = existingInstance;
61
+ setState((prev) => ({ ...prev, isReady: true, isLoading: false }));
62
+ return existingInstance;
63
+ }
64
+
65
+ const existingPromise = loadingPromiseRef.current || cache.loadingPromise;
66
+ if (existingPromise) {
67
+ try {
68
+ const instance = await existingPromise;
69
+ instanceRef.current = instance;
70
+ cache.instance = instance;
71
+ setState((prev) => ({ ...prev, isReady: true, isLoading: false }));
72
+ return instance;
73
+ } catch (error) {
74
+ setState((prev) => ({
75
+ ...prev,
76
+ isLoading: false,
77
+ error:
78
+ error instanceof Error ? error.message : "Failed to load model",
79
+ }));
80
+ throw error;
81
+ }
82
+ }
83
+
84
+ setState((prev) => ({
85
+ ...prev,
86
+ isLoading: true,
87
+ error: null,
88
+ progress: 0,
89
+ }));
90
+
91
+ abortControllerRef.current = new AbortController();
92
+
93
+ const loadingPromise = (async () => {
94
+ try {
95
+ const progressCallback = (progress: any) => {
96
+ // Only update progress for weights
97
+ if (
98
+ progress.status === "progress" &&
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
+ }
106
+ };
107
+
108
+ const tokenizer = await AutoTokenizer.from_pretrained(MODEL_ID, {
109
+ progress_callback: progressCallback,
110
+ });
111
+
112
+ const model = await AutoModelForCausalLM.from_pretrained(MODEL_ID, {
113
+ dtype: "q4f16",
114
+ device: "webgpu",
115
+ progress_callback: progressCallback,
116
+ });
117
+
118
+ const instance = { model, tokenizer };
119
+ instanceRef.current = instance;
120
+ cache.instance = instance;
121
+ loadingPromiseRef.current = null;
122
+ cache.loadingPromise = null;
123
+
124
+ setState((prev) => ({
125
+ ...prev,
126
+ isLoading: false,
127
+ isReady: true,
128
+ progress: 100,
129
+ }));
130
+ return instance;
131
+ } catch (error) {
132
+ loadingPromiseRef.current = null;
133
+ cache.loadingPromise = null;
134
+ setState((prev) => ({
135
+ ...prev,
136
+ isLoading: false,
137
+ error:
138
+ error instanceof Error ? error.message : "Failed to load model",
139
+ }));
140
+ throw error;
141
+ }
142
+ })();
143
+
144
+ loadingPromiseRef.current = loadingPromise;
145
+ cache.loadingPromise = loadingPromise;
146
+ return loadingPromise;
147
+ }, [modelId]);
148
+
149
+ const generateResponse = useCallback(
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) {
157
+ throw new Error("Model not loaded. Call loadModel() first.");
158
+ }
159
+
160
+ const { model, tokenizer } = instance;
161
+
162
+ // Apply chat template with tools
163
+ const input = tokenizer.apply_chat_template(messages, {
164
+ tools,
165
+ add_generation_prompt: true,
166
+ return_dict: true,
167
+ });
168
+
169
+ const streamer = onToken
170
+ ? new TextStreamer(tokenizer, {
171
+ skip_prompt: true,
172
+ skip_special_tokens: false,
173
+ callback_function: (token: string) => {
174
+ onToken(token);
175
+ },
176
+ })
177
+ : undefined;
178
+
179
+ // Generate the response
180
+ const { sequences, past_key_values } = await model.generate({
181
+ ...input,
182
+ past_key_values: pastKeyValuesRef.current,
183
+ max_new_tokens: 512,
184
+ do_sample: false,
185
+ streamer,
186
+ return_dict_in_generate: true,
187
+ });
188
+ pastKeyValuesRef.current = past_key_values;
189
+
190
+ // Decode the generated text with special tokens preserved (except final <|im_end|>) for tool call detection
191
+ const response = tokenizer
192
+ .batch_decode(sequences.slice(null, [input.input_ids.dims[1], null]), {
193
+ skip_special_tokens: false,
194
+ })[0]
195
+ .replace(/<\|im_end\|>$/, "");
196
+
197
+ return response;
198
+ },
199
+ [],
200
+ );
201
+
202
+ const clearPastKeyValues = useCallback(() => {
203
+ pastKeyValuesRef.current = null;
204
+ }, []);
205
+
206
+ const cleanup = useCallback(() => {
207
+ if (abortControllerRef.current) {
208
+ abortControllerRef.current.abort();
209
+ }
210
+ }, []);
211
+
212
+ useEffect(() => {
213
+ return cleanup;
214
+ }, [cleanup]);
215
+
216
+ useEffect(() => {
217
+ if (modelId && moduleCache[modelId]) {
218
+ const existingInstance =
219
+ instanceRef.current || moduleCache[modelId].instance;
220
+ if (existingInstance) {
221
+ instanceRef.current = existingInstance;
222
+ setState((prev) => ({ ...prev, isReady: true }));
223
+ }
224
+ }
225
+ }, [modelId]);
226
+
227
+ return {
228
+ ...state,
229
+ loadModel,
230
+ generateResponse,
231
+ clearPastKeyValues,
232
+ cleanup,
233
+ };
234
+ };
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/index.css CHANGED
@@ -1,13 +1 @@
1
- body {
2
- margin: 0;
3
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
4
- 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
5
- sans-serif;
6
- -webkit-font-smoothing: antialiased;
7
- -moz-osx-font-smoothing: grayscale;
8
- }
9
-
10
- code {
11
- font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
12
- monospace;
13
- }
 
1
+ @import "tailwindcss";
 
 
 
 
 
 
 
 
 
 
 
 
src/index.js DELETED
@@ -1,17 +0,0 @@
1
- import React from 'react';
2
- import ReactDOM from 'react-dom/client';
3
- import './index.css';
4
- import App from './App';
5
- import reportWebVitals from './reportWebVitals';
6
-
7
- const root = ReactDOM.createRoot(document.getElementById('root'));
8
- root.render(
9
- <React.StrictMode>
10
- <App />
11
- </React.StrictMode>
12
- );
13
-
14
- // If you want to start measuring performance in your app, pass a function
15
- // to log results (for example: reportWebVitals(console.log))
16
- // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
17
- reportWebVitals();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/logo.svg DELETED
src/main.tsx ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { StrictMode } from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import { HashRouter, 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
+ <HashRouter>
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
+ </HashRouter>
23
+ </StrictMode>
24
+ );
src/reportWebVitals.js DELETED
@@ -1,13 +0,0 @@
1
- const reportWebVitals = onPerfEntry => {
2
- if (onPerfEntry && onPerfEntry instanceof Function) {
3
- import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
4
- getCLS(onPerfEntry);
5
- getFID(onPerfEntry);
6
- getFCP(onPerfEntry);
7
- getLCP(onPerfEntry);
8
- getTTFB(onPerfEntry);
9
- });
10
- }
11
- };
12
-
13
- export default reportWebVitals;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/services/mcpClient.ts ADDED
@@ -0,0 +1,384 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
3
+ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
4
+ import type { Tool } from "@modelcontextprotocol/sdk/types.js";
5
+ import { MCP_SERVERS } from "../tools/mcp_servers";
6
+ import type {
7
+ MCPServerConfig,
8
+ MCPServerConnection,
9
+ MCPClientState,
10
+ MCPToolResult,
11
+ } from "../types/mcp.js";
12
+ import { MCP_CLIENT_CONFIG, STORAGE_KEYS } from "../config/constants";
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
+
19
+ constructor() {
20
+ // Load saved server configurations from localStorage
21
+ this.loadServerConfigs();
22
+
23
+ // If no servers are present, load initial list from MCP_SERVERS (imported)
24
+ if (this.connections.size === 0) {
25
+ MCP_SERVERS.forEach((config) => {
26
+ this.addServer(config);
27
+ });
28
+ }
29
+ }
30
+
31
+ // Add state change listener
32
+ addStateListener(listener: (state: MCPClientState) => void) {
33
+ this.listeners.push(listener);
34
+ }
35
+
36
+ // Remove state change listener
37
+ removeStateListener(listener: (state: MCPClientState) => void) {
38
+ const index = this.listeners.indexOf(listener);
39
+ if (index > -1) {
40
+ this.listeners.splice(index, 1);
41
+ }
42
+ }
43
+
44
+ // Notify all listeners of state changes
45
+ private notifyStateChange() {
46
+ const state = this.getState();
47
+ this.listeners.forEach((listener) => listener(state));
48
+ }
49
+
50
+ // Get current MCP client state
51
+ getState(): MCPClientState {
52
+ const servers: Record<string, MCPServerConnection> = {};
53
+ for (const [id, connection] of this.connections) {
54
+ servers[id] = connection;
55
+ }
56
+
57
+ return {
58
+ servers,
59
+ isLoading: false,
60
+ error: undefined,
61
+ };
62
+ }
63
+
64
+ // Load server configurations from localStorage
65
+ private loadServerConfigs() {
66
+ try {
67
+ const stored = localStorage.getItem(STORAGE_KEYS.MCP_SERVERS);
68
+ if (stored) {
69
+ const configs: MCPServerConfig[] = JSON.parse(stored);
70
+ configs.forEach((config) => {
71
+ const connection: MCPServerConnection = {
72
+ config,
73
+ isConnected: false,
74
+ tools: [],
75
+ lastError: undefined,
76
+ lastConnected: undefined,
77
+ };
78
+ this.connections.set(config.id, connection);
79
+ });
80
+ }
81
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
82
+ } catch (error) {
83
+ // Silently handle missing or corrupted config
84
+ }
85
+ }
86
+
87
+ // Save server configurations to localStorage
88
+ private saveServerConfigs() {
89
+ try {
90
+ const configs = Array.from(this.connections.values()).map(
91
+ (conn) => conn.config
92
+ );
93
+ localStorage.setItem(STORAGE_KEYS.MCP_SERVERS, JSON.stringify(configs));
94
+ } catch (error) {
95
+ // Handle storage errors gracefully
96
+ throw new Error(
97
+ `Failed to save server configuration: ${
98
+ error instanceof Error ? error.message : "Unknown error"
99
+ }`
100
+ );
101
+ }
102
+ }
103
+
104
+ // Add a new MCP server
105
+ async addServer(config: MCPServerConfig): Promise<void> {
106
+ const connection: MCPServerConnection = {
107
+ config,
108
+ isConnected: false,
109
+ tools: [],
110
+ lastError: undefined,
111
+ lastConnected: undefined,
112
+ };
113
+
114
+ this.connections.set(config.id, connection);
115
+ this.saveServerConfigs();
116
+ this.notifyStateChange();
117
+
118
+ // Auto-connect if enabled
119
+ if (config.enabled) {
120
+ await this.connectToServer(config.id);
121
+ }
122
+ }
123
+
124
+ // Remove an MCP server
125
+ async removeServer(serverId: string): Promise<void> {
126
+ // Disconnect first if connected
127
+ await this.disconnectFromServer(serverId);
128
+
129
+ // Remove from our maps
130
+ this.connections.delete(serverId);
131
+ this.clients.delete(serverId);
132
+
133
+ this.saveServerConfigs();
134
+ this.notifyStateChange();
135
+ }
136
+
137
+ // Connect to an MCP server
138
+ async connectToServer(serverId: string): Promise<void> {
139
+ const connection = this.connections.get(serverId);
140
+ if (!connection) {
141
+ throw new Error(`Server ${serverId} not found`);
142
+ }
143
+
144
+ if (connection.isConnected) {
145
+ return; // Already connected
146
+ }
147
+
148
+ try {
149
+ // Create client
150
+ const client = new Client(
151
+ {
152
+ name: MCP_CLIENT_CONFIG.NAME,
153
+ version: MCP_CLIENT_CONFIG.VERSION,
154
+ },
155
+ {
156
+ capabilities: {
157
+ tools: {},
158
+ },
159
+ }
160
+ );
161
+
162
+ // Create transport based on config
163
+ let transport;
164
+ const url = new URL(connection.config.url);
165
+
166
+ // Prepare headers for authentication
167
+ const headers: Record<string, string> = {};
168
+ if (connection.config.auth) {
169
+ switch (connection.config.auth.type) {
170
+ case "bearer":
171
+ if (connection.config.auth.token) {
172
+ headers[
173
+ "Authorization"
174
+ ] = `Bearer ${connection.config.auth.token}`;
175
+ }
176
+ break;
177
+ case "basic":
178
+ if (
179
+ connection.config.auth.username &&
180
+ connection.config.auth.password
181
+ ) {
182
+ const credentials = btoa(
183
+ `${connection.config.auth.username}:${connection.config.auth.password}`
184
+ );
185
+ headers["Authorization"] = `Basic ${credentials}`;
186
+ }
187
+ break;
188
+ case "oauth":
189
+ if (connection.config.auth.token) {
190
+ headers[
191
+ "Authorization"
192
+ ] = `Bearer ${connection.config.auth.token}`;
193
+ }
194
+ break;
195
+ }
196
+ }
197
+
198
+ switch (connection.config.transport) {
199
+ case "streamable-http":
200
+ transport = new StreamableHTTPClientTransport(url, {
201
+ requestInit:
202
+ Object.keys(headers).length > 0 ? { headers } : undefined,
203
+ });
204
+ break;
205
+
206
+ case "sse":
207
+ transport = new SSEClientTransport(url, {
208
+ requestInit:
209
+ Object.keys(headers).length > 0 ? { headers } : undefined,
210
+ });
211
+ break;
212
+
213
+ default:
214
+ throw new Error(
215
+ `Unsupported transport: ${connection.config.transport}`
216
+ );
217
+ }
218
+
219
+ // Set up error handling
220
+ client.onerror = (error) => {
221
+ connection.lastError = error.message;
222
+ connection.isConnected = false;
223
+ this.notifyStateChange();
224
+ };
225
+
226
+ // Connect to the server
227
+ await client.connect(transport);
228
+
229
+ // List available tools
230
+ const toolsResult = await client.listTools();
231
+
232
+ // Update connection state
233
+ connection.isConnected = true;
234
+ connection.tools = toolsResult.tools;
235
+ connection.lastError = undefined;
236
+ connection.lastConnected = new Date();
237
+
238
+ // Store client reference
239
+ this.clients.set(serverId, client);
240
+
241
+ this.notifyStateChange();
242
+ } catch (error) {
243
+ connection.isConnected = false;
244
+ connection.lastError =
245
+ error instanceof Error ? error.message : "Connection failed";
246
+ this.notifyStateChange();
247
+ throw error;
248
+ }
249
+ }
250
+
251
+ // Disconnect from an MCP server
252
+ async disconnectFromServer(serverId: string): Promise<void> {
253
+ const client = this.clients.get(serverId);
254
+ const connection = this.connections.get(serverId);
255
+
256
+ if (client) {
257
+ try {
258
+ await client.close();
259
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
260
+ } catch (error) {
261
+ // Handle disconnect error silently
262
+ }
263
+ this.clients.delete(serverId);
264
+ }
265
+
266
+ if (connection) {
267
+ connection.isConnected = false;
268
+ connection.tools = [];
269
+ this.notifyStateChange();
270
+ }
271
+ }
272
+
273
+ // Get all tools from all connected servers
274
+ getAllTools(): Tool[] {
275
+ const allTools: Tool[] = [];
276
+
277
+ for (const connection of this.connections.values()) {
278
+ if (connection.isConnected && connection.config.enabled) {
279
+ allTools.push(...connection.tools);
280
+ }
281
+ }
282
+
283
+ return allTools;
284
+ }
285
+
286
+ // Call a tool on an MCP server
287
+ async callTool(
288
+ serverId: string,
289
+ toolName: string,
290
+ args: Record<string, unknown>
291
+ ): Promise<MCPToolResult> {
292
+ const client = this.clients.get(serverId);
293
+ const connection = this.connections.get(serverId);
294
+
295
+ if (!client || !connection?.isConnected) {
296
+ throw new Error(`Not connected to server ${serverId}`);
297
+ }
298
+
299
+ try {
300
+ const result = await client.callTool({
301
+ name: toolName,
302
+ arguments: args,
303
+ });
304
+
305
+ return {
306
+ content: Array.isArray(result.content) ? result.content : [],
307
+ isError: Boolean(result.isError),
308
+ };
309
+ } catch (error) {
310
+ throw new Error(
311
+ `Tool execution failed (${toolName}): ${
312
+ error instanceof Error ? error.message : "Unknown error"
313
+ }`
314
+ );
315
+ }
316
+ }
317
+
318
+ // Test connection to a server without saving it
319
+ async testConnection(config: MCPServerConfig): Promise<boolean> {
320
+ try {
321
+ const client = new Client(
322
+ {
323
+ name: MCP_CLIENT_CONFIG.TEST_CLIENT_NAME,
324
+ version: MCP_CLIENT_CONFIG.VERSION,
325
+ },
326
+ {
327
+ capabilities: {
328
+ tools: {},
329
+ },
330
+ }
331
+ );
332
+
333
+ let transport;
334
+ const url = new URL(config.url);
335
+
336
+ switch (config.transport) {
337
+ case "streamable-http":
338
+ transport = new StreamableHTTPClientTransport(url);
339
+ break;
340
+
341
+ case "sse":
342
+ transport = new SSEClientTransport(url);
343
+ break;
344
+
345
+ default:
346
+ throw new Error(`Unsupported transport: ${config.transport}`);
347
+ }
348
+
349
+ await client.connect(transport);
350
+ await client.close();
351
+ return true;
352
+ } catch (error) {
353
+ throw new Error(
354
+ `Connection test failed: ${
355
+ error instanceof Error ? error.message : "Unknown error"
356
+ }`
357
+ );
358
+ }
359
+ }
360
+
361
+ // Connect to all enabled servers
362
+ async connectAll(): Promise<void> {
363
+ const promises = Array.from(this.connections.entries())
364
+ .filter(
365
+ ([, connection]) => connection.config.enabled && !connection.isConnected
366
+ )
367
+ .map(([serverId]) =>
368
+ this.connectToServer(serverId).catch(() => {
369
+ // Handle auto-connection error silently
370
+ })
371
+ );
372
+
373
+ await Promise.all(promises);
374
+ }
375
+
376
+ // Disconnect from all servers
377
+ async disconnectAll(): Promise<void> {
378
+ const promises = Array.from(this.connections.keys()).map((serverId) =>
379
+ this.disconnectFromServer(serverId)
380
+ );
381
+
382
+ await Promise.all(promises);
383
+ }
384
+ }
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/setupTests.js DELETED
@@ -1,5 +0,0 @@
1
- // jest-dom adds custom jest matchers for asserting on DOM nodes.
2
- // allows you to do things like:
3
- // expect(element).toHaveTextContent(/react/i)
4
- // learn more: https://github.com/testing-library/jest-dom
5
- import '@testing-library/jest-dom';
 
 
 
 
 
 
src/tools/index.ts ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ import TEMPLATE_TOOL from "./template.js?raw";
2
+ export const TEMPLATE = TEMPLATE_TOOL;
src/tools/mcp_servers.ts ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { MCPServerConfig } from "../types/mcp";
2
+
3
+ export const MCP_SERVERS: MCPServerConfig[] = [
4
+ {
5
+ id: "hf-transformers-demo-gitmcp",
6
+ name: "HuggingFace Transformers.js Documentation",
7
+ url: "https://gitmcp.io/huggingface/transformers.js",
8
+ enabled: true,
9
+ transport: "streamable-http",
10
+ },
11
+ {
12
+ id: "mcp-servers-docs",
13
+ name: "MCP Documentation",
14
+ url: "https://gitmcp.io/modelcontextprotocol/modelcontextprotocol",
15
+ enabled: true,
16
+ transport: "streamable-http",
17
+ },
18
+ ];
src/tools/template.js ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Description of the tool.
3
+ * @param {any} parameter1 - Description of the first parameter.
4
+ * @param {any} parameter2 - Description of the second parameter.
5
+ * @returns {any} Description of the return value.
6
+ */
7
+ export function new_tool(parameter1, parameter2) {
8
+ // TODO: Implement the tool logic here
9
+ return true; // Placeholder return value
10
+ }
11
+
12
+ export default (input, output) =>
13
+ React.createElement(
14
+ "div",
15
+ { className: "bg-amber-50 border border-amber-200 rounded-lg p-4" },
16
+ React.createElement(
17
+ "div",
18
+ { className: "flex items-center mb-2" },
19
+ React.createElement(
20
+ "div",
21
+ {
22
+ className:
23
+ "w-8 h-8 bg-amber-100 rounded-full flex items-center justify-center mr-3",
24
+ },
25
+ "🛠️"
26
+ ),
27
+ React.createElement(
28
+ "h3",
29
+ { className: "text-amber-900 font-semibold" },
30
+ "Tool Name"
31
+ )
32
+ ),
33
+ React.createElement(
34
+ "div",
35
+ { className: "text-sm space-y-1" },
36
+ React.createElement(
37
+ "p",
38
+ { className: "text-amber-700 font-medium" },
39
+ `Input: ${JSON.stringify(input)}`
40
+ ),
41
+ React.createElement(
42
+ "p",
43
+ { className: "text-amber-600 text-xs" },
44
+ `Output: ${output}`
45
+ )
46
+ )
47
+ );
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: "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.ts ADDED
@@ -0,0 +1,370 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ interface ParsedCall {
2
+ name: string;
3
+ positionalArgs: any[];
4
+ keywordArgs: Record<string, any>;
5
+ }
6
+
7
+ interface Schema {
8
+ name: string;
9
+ description: string;
10
+ parameters: {
11
+ type: string;
12
+ properties: Record<
13
+ string,
14
+ {
15
+ type: string;
16
+ description: string;
17
+ default?: any;
18
+ }
19
+ >;
20
+ required: string[];
21
+ };
22
+ }
23
+
24
+ interface JSDocParam {
25
+ type: string;
26
+ description: string;
27
+ isOptional: boolean;
28
+ defaultValue?: string;
29
+ }
30
+
31
+ const parseArguments = (argsString: string): string[] => {
32
+ const args: string[] = [];
33
+ let current = "";
34
+ let inQuotes = false;
35
+ let quoteChar = "";
36
+ let depth = 0;
37
+
38
+ for (let i = 0; i < argsString.length; i++) {
39
+ const char = argsString[i];
40
+
41
+ if (!inQuotes && (char === '"' || char === "'")) {
42
+ inQuotes = true;
43
+ quoteChar = char;
44
+ current += char;
45
+ } else if (inQuotes && char === quoteChar) {
46
+ inQuotes = false;
47
+ quoteChar = "";
48
+ current += char;
49
+ } else if (!inQuotes && char === "(") {
50
+ depth++;
51
+ current += char;
52
+ } else if (!inQuotes && char === ")") {
53
+ depth--;
54
+ current += char;
55
+ } else if (!inQuotes && char === "," && depth === 0) {
56
+ args.push(current.trim());
57
+ current = "";
58
+ } else {
59
+ current += char;
60
+ }
61
+ }
62
+
63
+ if (current.trim()) {
64
+ args.push(current.trim());
65
+ }
66
+
67
+ return args;
68
+ };
69
+
70
+ export const extractPythonicCalls = (toolCallContent: string): string[] => {
71
+ try {
72
+ const cleanContent = toolCallContent.trim();
73
+
74
+ try {
75
+ const parsed = JSON.parse(cleanContent);
76
+ if (Array.isArray(parsed)) {
77
+ return parsed;
78
+ }
79
+ } catch {
80
+ // Fallback to manual parsing
81
+ }
82
+
83
+ if (cleanContent.startsWith("[") && cleanContent.endsWith("]")) {
84
+ const inner = cleanContent.slice(1, -1).trim();
85
+ if (!inner) return [];
86
+ return parseArguments(inner).map((call) =>
87
+ call.trim().replace(/^['"]|['"]$/g, ""),
88
+ );
89
+ }
90
+
91
+ return [cleanContent];
92
+ } catch (error) {
93
+ console.error("Error parsing tool calls:", error);
94
+ return [];
95
+ }
96
+ };
97
+
98
+ export const parsePythonicCalls = (command: string): ParsedCall | null => {
99
+ const callMatch = command.match(/^([a-zA-Z0-9_]+)\((.*)\)$/);
100
+ if (!callMatch) return null;
101
+
102
+ const [, name, argsStr] = callMatch;
103
+ const args = parseArguments(argsStr);
104
+ const positionalArgs: any[] = [];
105
+ const keywordArgs: Record<string, any> = {};
106
+
107
+ for (const arg of args) {
108
+ const kwargMatch = arg.match(/^([a-zA-Z0-9_]+)\s*=\s*(.*)$/);
109
+ if (kwargMatch) {
110
+ const [, key, value] = kwargMatch;
111
+ try {
112
+ keywordArgs[key] = JSON.parse(value);
113
+ } catch {
114
+ keywordArgs[key] = value;
115
+ }
116
+ } else {
117
+ try {
118
+ positionalArgs.push(JSON.parse(arg));
119
+ } catch {
120
+ positionalArgs.push(arg);
121
+ }
122
+ }
123
+ }
124
+ return { name, positionalArgs, keywordArgs };
125
+ };
126
+
127
+ export const extractFunctionAndRenderer = (
128
+ code: string,
129
+ ): { functionCode: string; rendererCode?: string } => {
130
+ if (typeof code !== "string") {
131
+ return { functionCode: code };
132
+ }
133
+
134
+ const exportMatch = code.match(/export\s+default\s+/);
135
+ if (!exportMatch) {
136
+ return { functionCode: code };
137
+ }
138
+
139
+ const exportIndex = exportMatch.index!;
140
+ const functionCode = code.substring(0, exportIndex).trim();
141
+ const rendererCode = code.substring(exportIndex).trim();
142
+
143
+ return { functionCode, rendererCode };
144
+ };
145
+
146
+ /**
147
+ * Helper function to extract JSDoc parameters from JSDoc comments.
148
+ */
149
+ const extractJSDocParams = (
150
+ jsdoc: string,
151
+ ): Record<string, JSDocParam & { jsdocDefault?: string }> => {
152
+ const jsdocParams: Record<string, JSDocParam & { jsdocDefault?: string }> =
153
+ {};
154
+ const lines = jsdoc
155
+ .split("\n")
156
+ .map((line) => line.trim().replace(/^\*\s?/, ""));
157
+ const paramRegex =
158
+ /@param\s+\{([^}]+)\}\s+(\[?[a-zA-Z0-9_]+(?:=[^\]]+)?\]?|\S+)\s*-?\s*(.*)?/;
159
+
160
+ for (const line of lines) {
161
+ const paramMatch = line.match(paramRegex);
162
+ if (paramMatch) {
163
+ let [, type, namePart, description] = paramMatch;
164
+ description = description || "";
165
+ let isOptional = false;
166
+ let name = namePart;
167
+ let jsdocDefault: string | undefined = undefined;
168
+
169
+ if (name.startsWith("[") && name.endsWith("]")) {
170
+ isOptional = true;
171
+ name = name.slice(1, -1);
172
+ }
173
+ if (name.includes("=")) {
174
+ const [n, def] = name.split("=");
175
+ name = n.trim();
176
+ jsdocDefault = def.trim().replace(/['"]/g, "");
177
+ }
178
+
179
+ jsdocParams[name] = {
180
+ type: type.toLowerCase(),
181
+ description: description.trim(),
182
+ isOptional,
183
+ defaultValue: undefined,
184
+ jsdocDefault,
185
+ };
186
+ }
187
+ }
188
+ return jsdocParams;
189
+ };
190
+
191
+ /**
192
+ * Helper function to extract function signature information.
193
+ */
194
+ const extractFunctionSignature = (
195
+ functionCode: string,
196
+ ): {
197
+ name: string;
198
+ params: { name: string; defaultValue?: string }[];
199
+ } | null => {
200
+ const functionSignatureMatch = functionCode.match(
201
+ /function\s+([a-zA-Z0-9_]+)\s*\(([^)]*)\)/,
202
+ );
203
+ if (!functionSignatureMatch) {
204
+ return null;
205
+ }
206
+
207
+ const functionName = functionSignatureMatch[1];
208
+ const params = functionSignatureMatch[2]
209
+ .split(",")
210
+ .map((p) => p.trim())
211
+ .filter(Boolean)
212
+ .map((p) => {
213
+ const [name, defaultValue] = p.split("=").map((s) => s.trim());
214
+ return { name, defaultValue };
215
+ });
216
+
217
+ return { name: functionName, params };
218
+ };
219
+
220
+ export const generateSchemaFromCode = (code: string): Schema => {
221
+ const { functionCode } = extractFunctionAndRenderer(code);
222
+
223
+ if (typeof functionCode !== "string") {
224
+ return {
225
+ name: "invalid_code",
226
+ description: "Code is not a valid string.",
227
+ parameters: { type: "object", properties: {}, required: [] },
228
+ };
229
+ }
230
+
231
+ // 1. Extract function signature, name, and parameter names directly from the code
232
+ const signatureInfo = extractFunctionSignature(functionCode);
233
+ if (!signatureInfo) {
234
+ return {
235
+ name: "invalid_function",
236
+ description: "Could not parse function signature.",
237
+ parameters: { type: "object", properties: {}, required: [] },
238
+ };
239
+ }
240
+
241
+ const { name: functionName, params: paramsFromSignature } = signatureInfo;
242
+
243
+ const schema: Schema = {
244
+ name: functionName,
245
+ description: "",
246
+ parameters: {
247
+ type: "object",
248
+ properties: {},
249
+ required: [],
250
+ },
251
+ };
252
+
253
+ // 2. Parse JSDoc comments to get descriptions and types
254
+ const jsdocMatch = functionCode.match(/\/\*\*([\s\S]*?)\*\//);
255
+ let jsdocParams: Record<string, JSDocParam & { jsdocDefault?: string }> = {};
256
+ if (jsdocMatch) {
257
+ const jsdoc = jsdocMatch[1];
258
+ jsdocParams = extractJSDocParams(jsdoc);
259
+
260
+ const descriptionLines = jsdoc
261
+ .split("\n")
262
+ .map((line) => line.trim().replace(/^\*\s?/, ""))
263
+ .filter((line) => !line.startsWith("@") && line);
264
+
265
+ schema.description = descriptionLines.join(" ").trim();
266
+ }
267
+
268
+ // 3. Combine signature parameters with JSDoc info
269
+ for (const param of paramsFromSignature) {
270
+ const paramName = param.name;
271
+ const jsdocInfo = jsdocParams[paramName];
272
+ schema.parameters.properties[paramName] = {
273
+ type: jsdocInfo ? jsdocInfo.type : "any",
274
+ description: jsdocInfo ? jsdocInfo.description : "",
275
+ };
276
+
277
+ // Prefer default from signature, then from JSDoc
278
+ if (param.defaultValue !== undefined) {
279
+ // Try to parse as JSON, fallback to string
280
+ try {
281
+ schema.parameters.properties[paramName].default = JSON.parse(
282
+ param.defaultValue.replace(/'/g, '"'),
283
+ );
284
+ } catch {
285
+ schema.parameters.properties[paramName].default = param.defaultValue;
286
+ }
287
+ } else if (jsdocInfo && jsdocInfo.jsdocDefault !== undefined) {
288
+ schema.parameters.properties[paramName].default = jsdocInfo.jsdocDefault;
289
+ }
290
+
291
+ // A parameter is required if:
292
+ // - Not optional in JSDoc
293
+ // - No default in signature
294
+ // - No default in JSDoc
295
+ const hasDefault =
296
+ param.defaultValue !== undefined ||
297
+ (jsdocInfo && jsdocInfo.jsdocDefault !== undefined);
298
+ if (!jsdocInfo || (!jsdocInfo.isOptional && !hasDefault)) {
299
+ schema.parameters.required.push(paramName);
300
+ }
301
+ }
302
+
303
+ return schema;
304
+ };
305
+
306
+ /**
307
+ * Extracts tool call content from a string using the tool call markers.
308
+ */
309
+ export const extractToolCallContent = (content: string): string | null => {
310
+ const toolCallMatch = content.match(
311
+ /<\|tool_call_start\|>(.*?)<\|tool_call_end\|>/s,
312
+ );
313
+ return toolCallMatch ? toolCallMatch[1].trim() : null;
314
+ };
315
+
316
+ /**
317
+ * Maps positional and keyword arguments to named parameters based on schema.
318
+ */
319
+ export const mapArgsToNamedParams = (
320
+ paramNames: string[],
321
+ positionalArgs: any[],
322
+ keywordArgs: Record<string, any>,
323
+ ): Record<string, any> => {
324
+ const namedParams: Record<string, any> = Object.create(null);
325
+ positionalArgs.forEach((arg, idx) => {
326
+ if (idx < paramNames.length) {
327
+ namedParams[paramNames[idx]] = arg;
328
+ }
329
+ });
330
+ Object.assign(namedParams, keywordArgs);
331
+ return namedParams;
332
+ };
333
+
334
+ export const getErrorMessage = (error: unknown): string => {
335
+ if (error instanceof Error) {
336
+ return error.message;
337
+ }
338
+ if (typeof error === "string") {
339
+ return error;
340
+ }
341
+ if (error && typeof error === "object") {
342
+ return JSON.stringify(error);
343
+ }
344
+ return String(error);
345
+ };
346
+
347
+ /**
348
+ * Adapted from https://stackoverflow.com/questions/11381673/detecting-a-mobile-browser.
349
+ */
350
+ export function isMobileOrTablet() {
351
+ let check = false;
352
+ (function (a: string) {
353
+ if (
354
+ /(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino|android|ipad|playbook|silk/i.test(
355
+ a,
356
+ ) ||
357
+ /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(
358
+ a.slice(0, 4),
359
+ )
360
+ )
361
+ check = true;
362
+ })(
363
+ navigator.userAgent ||
364
+ navigator.vendor ||
365
+ ("opera" in window && typeof window.opera === "string"
366
+ ? window.opera
367
+ : ""),
368
+ );
369
+ return check;
370
+ }
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
+ };