Major refactor: Multi-user Chrome MCP extension with remote server architecture
This commit is contained in:
@@ -1,4 +1,10 @@
|
||||
# Chrome Extension Private Key
|
||||
# Copy this file to .env and replace with your actual private key
|
||||
# Chrome Extension Configuration
|
||||
# Copy this file to .env and replace with your actual values
|
||||
|
||||
# Remote Server Configuration
|
||||
VITE_REMOTE_SERVER_HOST=127.0.0.1
|
||||
VITE_REMOTE_SERVER_PORT=3001
|
||||
|
||||
# Chrome Extension Private Key (optional)
|
||||
# This key is used for Chrome extension packaging and should be kept secure
|
||||
CHROME_EXTENSION_KEY=YOUR_PRIVATE_KEY_HERE
|
||||
|
133
app/chrome-extension/PERSISTENT_CONNECTION_CHANGES.md
Normal file
133
app/chrome-extension/PERSISTENT_CONNECTION_CHANGES.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# Persistent Connection Implementation Summary
|
||||
|
||||
## Overview
|
||||
Modified the Chrome extension to implement persistent connection management that maintains connections until explicitly disconnected by the user.
|
||||
|
||||
## Key Changes Made
|
||||
|
||||
### 1. Enhanced RemoteServerClient (`utils/remote-server-client.ts`)
|
||||
|
||||
#### Connection State Persistence
|
||||
- **Added persistent connection state management**:
|
||||
- `persistentConnectionEnabled = true` by default
|
||||
- `connectionStateKey = 'remoteServerConnectionState'` for storage
|
||||
- Automatic state saving/loading to chrome.storage.local
|
||||
|
||||
#### New Methods Added
|
||||
- `saveConnectionState()`: Saves connection state to chrome storage
|
||||
- `loadConnectionState()`: Loads and restores connection state on startup
|
||||
- `clearConnectionState()`: Clears saved state on manual disconnect
|
||||
- `setPersistentConnection(enabled)`: Enable/disable persistent behavior
|
||||
- `isPersistentConnectionEnabled()`: Get current persistence setting
|
||||
|
||||
#### Enhanced Reconnection Logic
|
||||
- **Increased max reconnection attempts**: From 10 to 50 for persistent connections
|
||||
- **Extended reconnection delays**: Up to 60 seconds for persistent connections
|
||||
- **Smarter reconnection**: Only attempts reconnection for unexpected disconnections
|
||||
- **Connection restoration**: Automatically restores connections within 24 hours
|
||||
|
||||
#### Connection Lifecycle Improvements
|
||||
- **State saving on connect**: Automatically saves state when connection established
|
||||
- **State clearing on disconnect**: Clears state only on manual disconnect
|
||||
- **Robust error handling**: Better handling of connection timeouts and errors
|
||||
|
||||
### 2. Enhanced Background Script (`entrypoints/background/index.ts`)
|
||||
|
||||
#### Browser Event Listeners
|
||||
- **Added `initBrowserEventListeners()`** function with listeners for:
|
||||
- `chrome.runtime.onStartup`: Browser startup detection
|
||||
- `chrome.runtime.onInstalled`: Extension install/update events
|
||||
- `chrome.runtime.onSuspend`: Browser suspension events
|
||||
- `chrome.tabs.onActivated`: Tab switch monitoring
|
||||
- `chrome.windows.onFocusChanged`: Window focus monitoring
|
||||
|
||||
#### Connection Health Monitoring
|
||||
- **Added `startConnectionHealthCheck()`**: 5-minute interval health checks
|
||||
- **Periodic status logging**: Regular connection status verification
|
||||
- **Proactive monitoring**: Detects and logs connection state changes
|
||||
|
||||
### 3. Enhanced Popup UI (`entrypoints/popup/App.vue`)
|
||||
|
||||
#### Visual Indicators
|
||||
- **Persistent connection badge**: "🔗 Persistent: Active" indicator
|
||||
- **Enhanced status text**: "Connected (Persistent)" vs "Disconnected - Click Connect for persistent connection"
|
||||
- **Persistent info message**: "🔗 Connection will persist until manually disconnected"
|
||||
|
||||
#### CSS Styling
|
||||
- **`.persistent-indicator`**: Styling for persistent connection elements
|
||||
- **`.persistent-badge`**: Green gradient badge with shadow
|
||||
- **`.persistent-info`**: Info box with green accent border
|
||||
|
||||
#### Status Text Updates
|
||||
- **Clear messaging**: Emphasizes persistent nature of connections
|
||||
- **User guidance**: Explains that connections persist until manual disconnect
|
||||
|
||||
## Technical Implementation Details
|
||||
|
||||
### Connection State Storage
|
||||
```javascript
|
||||
const state = {
|
||||
wasConnected: boolean,
|
||||
connectionTime: number,
|
||||
serverUrl: string,
|
||||
lastSaveTime: number
|
||||
}
|
||||
```
|
||||
|
||||
### Reconnection Strategy
|
||||
- **Exponential backoff**: 5s, 10s, 20s, 40s, up to 60s intervals
|
||||
- **Persistent attempts**: Up to 50 attempts for persistent connections
|
||||
- **Smart restoration**: Only restores connections from last 24 hours
|
||||
|
||||
### Browser Event Handling
|
||||
- **Tab switches**: Connection maintained across all tab operations
|
||||
- **Window focus**: Connection persists during window focus changes
|
||||
- **Browser suspension**: State saved before suspension, restored after
|
||||
- **Extension updates**: Connection state preserved across updates
|
||||
|
||||
## Behavior Changes
|
||||
|
||||
### Before Implementation
|
||||
- Manual connection required each session
|
||||
- Connections might not survive browser events
|
||||
- Limited reconnection attempts (10)
|
||||
- No connection state persistence
|
||||
|
||||
### After Implementation
|
||||
- **"Connect once, stay connected"** behavior
|
||||
- **Connections persist across**:
|
||||
- Popup open/close cycles
|
||||
- Browser tab switches
|
||||
- Window focus changes
|
||||
- Extended idle periods
|
||||
- Browser suspension/resume
|
||||
- **Robust reconnection**: Up to 50 attempts with smart backoff
|
||||
- **State restoration**: Automatic connection restoration after browser restart
|
||||
- **Manual disconnect only**: Connections only terminate when explicitly disconnected
|
||||
|
||||
## User Experience Improvements
|
||||
|
||||
### Clear Visual Feedback
|
||||
- Persistent connection status clearly indicated
|
||||
- Real-time connection duration display
|
||||
- Visual badges and indicators for connection state
|
||||
|
||||
### Predictable Behavior
|
||||
- Users know connections will persist until manually disconnected
|
||||
- No unexpected disconnections during tool operations
|
||||
- Consistent behavior across browser lifecycle events
|
||||
|
||||
### Robust Connectivity
|
||||
- Automatic reconnection when server becomes available
|
||||
- Connection state restoration after browser restart
|
||||
- Extended retry attempts for better reliability
|
||||
|
||||
## Testing
|
||||
- Comprehensive test plan provided in `PERSISTENT_CONNECTION_TEST_PLAN.md`
|
||||
- Covers all aspects of persistent connection behavior
|
||||
- Includes verification points and success criteria
|
||||
|
||||
## Compatibility
|
||||
- Maintains backward compatibility with existing MCP clients
|
||||
- No breaking changes to tool execution or API
|
||||
- Enhanced reliability without changing core functionality
|
170
app/chrome-extension/PERSISTENT_CONNECTION_TEST_PLAN.md
Normal file
170
app/chrome-extension/PERSISTENT_CONNECTION_TEST_PLAN.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# Persistent Connection Test Plan
|
||||
|
||||
## Overview
|
||||
This test plan verifies that the Chrome extension implements persistent connection management as requested:
|
||||
- Connections remain active after manual connect until explicit disconnect
|
||||
- Connections persist across popup open/close cycles
|
||||
- Connections survive browser events (tab switches, window focus changes, idle periods)
|
||||
- No automatic disconnection after tool operations
|
||||
|
||||
## Prerequisites
|
||||
1. **Remote Server Running**: Start the remote server on `ws://localhost:3001/chrome`
|
||||
```bash
|
||||
cd app/remote-server
|
||||
npm run dev
|
||||
```
|
||||
|
||||
2. **Extension Loaded**: Load the built extension from `app/chrome-extension/.output/chrome-mv3`
|
||||
|
||||
## Test Cases
|
||||
|
||||
### Test 1: Basic Persistent Connection
|
||||
**Objective**: Verify connection persists after popup close
|
||||
|
||||
**Steps**:
|
||||
1. Open Chrome extension popup
|
||||
2. Click "Connect" button
|
||||
3. Verify connection status shows "Connected (Persistent)" with green checkmark
|
||||
4. Note the persistent connection indicator: "🔗 Persistent: Active"
|
||||
5. Close the popup window
|
||||
6. Wait 30 seconds
|
||||
7. Reopen the popup
|
||||
|
||||
**Expected Result**:
|
||||
- Connection status still shows "Connected (Persistent)"
|
||||
- Connection time continues counting from original connection
|
||||
- No reconnection attempts logged
|
||||
|
||||
### Test 2: Connection Persistence Across Browser Events
|
||||
**Objective**: Verify connection survives browser lifecycle events
|
||||
|
||||
**Steps**:
|
||||
1. Establish connection (Test 1)
|
||||
2. Switch between multiple tabs
|
||||
3. Change window focus to different applications
|
||||
4. Minimize/restore browser window
|
||||
5. Open new browser windows
|
||||
6. Check popup status after each event
|
||||
|
||||
**Expected Result**:
|
||||
- Connection remains active throughout all events
|
||||
- No disconnection or reconnection attempts
|
||||
- Status consistently shows "Connected (Persistent)"
|
||||
|
||||
### Test 3: Tool Execution Without Disconnection
|
||||
**Objective**: Verify connection persists during and after tool operations
|
||||
|
||||
**Steps**:
|
||||
1. Establish connection
|
||||
2. Use Cherry Studio or another MCP client to send tool requests:
|
||||
- `chrome_navigate` to navigate to a website
|
||||
- `chrome_screenshot` to take screenshots
|
||||
- `chrome_extract_content` to extract page content
|
||||
3. Execute multiple tool operations in sequence
|
||||
4. Check connection status after each operation
|
||||
|
||||
**Expected Result**:
|
||||
- All tool operations complete successfully
|
||||
- Connection remains active after each operation
|
||||
- No automatic disconnection after tool completion
|
||||
|
||||
### Test 4: Extended Idle Period Test
|
||||
**Objective**: Verify connection survives long idle periods
|
||||
|
||||
**Steps**:
|
||||
1. Establish connection
|
||||
2. Leave browser idle for 30 minutes
|
||||
3. Do not interact with the extension or browser
|
||||
4. After 30 minutes, check popup status
|
||||
5. Test tool functionality by sending a simple request
|
||||
|
||||
**Expected Result**:
|
||||
- Connection remains active after idle period
|
||||
- Tool operations work immediately without reconnection
|
||||
- Connection time shows full duration including idle time
|
||||
|
||||
### Test 5: Manual Disconnect Only
|
||||
**Objective**: Verify connection only terminates on explicit disconnect
|
||||
|
||||
**Steps**:
|
||||
1. Establish connection
|
||||
2. Perform various activities (tabs, tools, idle time)
|
||||
3. Click "Disconnect" button in popup
|
||||
4. Verify disconnection
|
||||
5. Close and reopen popup
|
||||
|
||||
**Expected Result**:
|
||||
- Connection terminates only when "Disconnect" is clicked
|
||||
- After disconnect, status shows "Disconnected - Click Connect for persistent connection"
|
||||
- Popup reopening shows disconnected state
|
||||
- No automatic reconnection attempts
|
||||
|
||||
### Test 6: Browser Restart Connection Restoration
|
||||
**Objective**: Verify connection state restoration after browser restart
|
||||
|
||||
**Steps**:
|
||||
1. Establish connection
|
||||
2. Close entire browser (all windows)
|
||||
3. Restart browser
|
||||
4. Open extension popup immediately
|
||||
|
||||
**Expected Result**:
|
||||
- Extension attempts to restore previous connection
|
||||
- If server is still running, connection is re-established automatically
|
||||
- If successful, shows "Connected (Persistent)" status
|
||||
|
||||
### Test 7: Server Reconnection Behavior
|
||||
**Objective**: Verify robust reconnection when server becomes unavailable
|
||||
|
||||
**Steps**:
|
||||
1. Establish connection
|
||||
2. Stop the remote server
|
||||
3. Wait for connection loss detection
|
||||
4. Restart the remote server
|
||||
5. Monitor reconnection attempts
|
||||
|
||||
**Expected Result**:
|
||||
- Extension detects connection loss
|
||||
- Automatic reconnection attempts begin
|
||||
- Connection is restored when server comes back online
|
||||
- Persistent connection behavior resumes
|
||||
|
||||
## Verification Points
|
||||
|
||||
### UI Indicators
|
||||
- ✅ Status shows "Connected (Persistent)" when connected
|
||||
- ✅ Persistent badge shows "🔗 Persistent: Active"
|
||||
- ✅ Info text: "🔗 Connection will persist until manually disconnected"
|
||||
- ✅ Connection time continues counting accurately
|
||||
- ✅ Disconnect button changes to "Disconnect" when connected
|
||||
|
||||
### Console Logs
|
||||
Monitor browser console for these log messages:
|
||||
- `Background: Remote server client initialized (not connected)`
|
||||
- `Background: Browser event listeners initialized for connection persistence`
|
||||
- `Background: Connection health check started (5-minute intervals)`
|
||||
- `RemoteServerClient: Connection state saved`
|
||||
- `Background: Tab switched to X, connection maintained`
|
||||
- `Background: Window focus changed to X, connection maintained`
|
||||
|
||||
### Connection State Persistence
|
||||
- Connection state is saved to chrome.storage.local
|
||||
- State includes: wasConnected, connectionTime, serverUrl, lastSaveTime
|
||||
- State is restored on extension startup if recent (within 24 hours)
|
||||
|
||||
## Success Criteria
|
||||
All test cases pass with:
|
||||
1. ✅ Connections persist until manual disconnect
|
||||
2. ✅ No automatic disconnection after tool operations
|
||||
3. ✅ Connections survive all browser lifecycle events
|
||||
4. ✅ UI clearly indicates persistent connection status
|
||||
5. ✅ Robust reconnection when server connectivity is restored
|
||||
6. ✅ Connection state restoration after browser restart
|
||||
|
||||
## Troubleshooting
|
||||
If tests fail, check:
|
||||
1. Remote server is running on correct port (3001)
|
||||
2. Extension has proper permissions
|
||||
3. Browser console for error messages
|
||||
4. Chrome storage for saved connection state
|
||||
5. Network connectivity between extension and server
|
178
app/chrome-extension/USER_ID_GUIDE.md
Normal file
178
app/chrome-extension/USER_ID_GUIDE.md
Normal file
@@ -0,0 +1,178 @@
|
||||
# Chrome Extension User ID Guide
|
||||
|
||||
## Overview
|
||||
|
||||
The Chrome extension automatically generates and manages unique user IDs when connecting to the remote server. This guide explains how to access and use these user IDs.
|
||||
|
||||
## How User IDs Work
|
||||
|
||||
### 1. **Automatic Generation**
|
||||
- Each Chrome extension instance generates a unique user ID in the format: `user_{timestamp}_{random}`
|
||||
- Example: `user_1704067200000_abc123def456`
|
||||
- User IDs are persistent across browser sessions (stored in chrome.storage.local)
|
||||
|
||||
### 2. **User ID Display in Popup**
|
||||
When connected to the remote server, the popup will show:
|
||||
- **User ID section** with the current user ID
|
||||
- **Truncated display** for long IDs (shows first 8 and last 8 characters)
|
||||
- **Copy button** (📋) to copy the full user ID to clipboard
|
||||
- **Tooltip** showing the full user ID on hover
|
||||
|
||||
## Getting User ID Programmatically
|
||||
|
||||
### 1. **From Popup/Content Scripts**
|
||||
```javascript
|
||||
// Send message to background script to get user ID
|
||||
const response = await chrome.runtime.sendMessage({ type: 'getCurrentUserId' });
|
||||
if (response && response.success) {
|
||||
const userId = response.userId;
|
||||
console.log('Current User ID:', userId);
|
||||
} else {
|
||||
console.log('No user ID available or not connected');
|
||||
}
|
||||
```
|
||||
|
||||
### 2. **From Background Script**
|
||||
```javascript
|
||||
import { getRemoteServerClient } from './background/index';
|
||||
|
||||
// Get the remote server client instance
|
||||
const client = getRemoteServerClient();
|
||||
if (client) {
|
||||
const userId = await client.getCurrentUserId();
|
||||
console.log('Current User ID:', userId);
|
||||
}
|
||||
```
|
||||
|
||||
### 3. **Direct Storage Access**
|
||||
```javascript
|
||||
// Get user ID directly from chrome storage
|
||||
const result = await chrome.storage.local.get(['chrome_extension_user_id']);
|
||||
const userId = result.chrome_extension_user_id;
|
||||
console.log('Stored User ID:', userId);
|
||||
```
|
||||
|
||||
## User ID Lifecycle
|
||||
|
||||
### 1. **Generation**
|
||||
- User ID is generated on first connection to remote server
|
||||
- Stored in `chrome.storage.local` with key `chrome_extension_user_id`
|
||||
- Persists across browser restarts and extension reloads
|
||||
|
||||
### 2. **Usage**
|
||||
- Sent to remote server during connection handshake
|
||||
- Used for session management and routing
|
||||
- Enables multi-user support with session isolation
|
||||
|
||||
### 3. **Display**
|
||||
- Shown in popup when connected to remote server
|
||||
- Updates automatically when connection status changes
|
||||
- Cleared from display when disconnected
|
||||
|
||||
## Remote Server Integration
|
||||
|
||||
### 1. **Connection Info**
|
||||
When connecting, the extension sends:
|
||||
```javascript
|
||||
{
|
||||
type: 'connection_info',
|
||||
userId: 'user_1704067200000_abc123def456',
|
||||
userAgent: navigator.userAgent,
|
||||
timestamp: Date.now(),
|
||||
extensionId: chrome.runtime.id
|
||||
}
|
||||
```
|
||||
|
||||
### 2. **Server-Side Access**
|
||||
The remote server receives and uses the user ID for:
|
||||
- Session management
|
||||
- LiveKit room assignment (`mcp-chrome-user-{userId}`)
|
||||
- Command routing
|
||||
- User isolation
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### 1. **User ID Not Showing**
|
||||
- Ensure you're connected to the remote server
|
||||
- Check browser console for connection errors
|
||||
- Verify remote server is running and accessible
|
||||
|
||||
### 2. **User ID Changes**
|
||||
- User IDs should persist across sessions
|
||||
- If changing frequently, check chrome.storage permissions
|
||||
- Clear extension data to force new user ID generation
|
||||
|
||||
### 3. **Copy Function Not Working**
|
||||
- Ensure clipboard permissions are granted
|
||||
- Check for HTTPS context requirements
|
||||
- Fallback: manually select and copy from tooltip
|
||||
|
||||
## API Reference
|
||||
|
||||
### Background Script Messages
|
||||
|
||||
#### `getCurrentUserId`
|
||||
```javascript
|
||||
// Request
|
||||
{ type: 'getCurrentUserId' }
|
||||
|
||||
// Response
|
||||
{
|
||||
success: true,
|
||||
userId: 'user_1704067200000_abc123def456'
|
||||
}
|
||||
// or
|
||||
{
|
||||
success: false,
|
||||
error: 'Remote server client not initialized'
|
||||
}
|
||||
```
|
||||
|
||||
### Storage Keys
|
||||
|
||||
#### `chrome_extension_user_id`
|
||||
- **Type**: String
|
||||
- **Format**: `user_{timestamp}_{random}`
|
||||
- **Persistence**: Permanent (until extension data cleared)
|
||||
- **Access**: chrome.storage.local
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always check connection status** before requesting user ID
|
||||
2. **Handle null/undefined user IDs** gracefully
|
||||
3. **Use the background script API** for reliable access
|
||||
4. **Don't hardcode user IDs** - they should be dynamic
|
||||
5. **Respect user privacy** - user IDs are anonymous but unique
|
||||
|
||||
## Example Implementation
|
||||
|
||||
```javascript
|
||||
// Complete example of getting and using user ID
|
||||
async function handleUserIdExample() {
|
||||
try {
|
||||
// Get current user ID
|
||||
const response = await chrome.runtime.sendMessage({
|
||||
type: 'getCurrentUserId'
|
||||
});
|
||||
|
||||
if (response && response.success && response.userId) {
|
||||
console.log('✅ User ID:', response.userId);
|
||||
|
||||
// Use the user ID for your application logic
|
||||
await processUserSpecificData(response.userId);
|
||||
|
||||
} else {
|
||||
console.log('❌ No user ID available');
|
||||
console.log('Make sure you are connected to the remote server');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to get user ID:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function processUserSpecificData(userId) {
|
||||
// Your application logic here
|
||||
console.log(`Processing data for user: ${userId}`);
|
||||
}
|
||||
```
|
@@ -442,5 +442,61 @@
|
||||
"pagesUnit": {
|
||||
"message": "pages",
|
||||
"description": "Pages count unit"
|
||||
},
|
||||
"remoteServerConfigLabel": {
|
||||
"message": "Remote Server Configuration",
|
||||
"description": "Main section header for remote server settings"
|
||||
},
|
||||
"remoteServerStatusLabel": {
|
||||
"message": "Remote Server Status",
|
||||
"description": "Remote server status label"
|
||||
},
|
||||
"remoteMcpServerConfigLabel": {
|
||||
"message": "Remote MCP Server Configuration",
|
||||
"description": "Remote MCP server config label"
|
||||
},
|
||||
"serverEndpointLabel": {
|
||||
"message": "Server Endpoint",
|
||||
"description": "Server endpoint label"
|
||||
},
|
||||
"reconnectAttemptsLabel": {
|
||||
"message": "Reconnect Attempts",
|
||||
"description": "Reconnect attempts label"
|
||||
},
|
||||
"connectionTimeLabel": {
|
||||
"message": "Connected For",
|
||||
"description": "Connection duration label"
|
||||
},
|
||||
"remoteServerConnectedStatus": {
|
||||
"message": "Connected to Remote Server",
|
||||
"description": "Remote server connected status"
|
||||
},
|
||||
"remoteServerConnectingStatus": {
|
||||
"message": "Connecting to Remote Server...",
|
||||
"description": "Remote server connecting status"
|
||||
},
|
||||
"remoteServerDisconnectedStatus": {
|
||||
"message": "Disconnected from Remote Server",
|
||||
"description": "Remote server disconnected status"
|
||||
},
|
||||
"remoteServerErrorStatus": {
|
||||
"message": "Remote Server Error",
|
||||
"description": "Remote server error status"
|
||||
},
|
||||
"copiedButton": {
|
||||
"message": "✅ Copied!",
|
||||
"description": "Config copied button text"
|
||||
},
|
||||
"copyFailedButton": {
|
||||
"message": "❌ Copy Failed",
|
||||
"description": "Config copy failed button text"
|
||||
},
|
||||
"recommendedLabel": {
|
||||
"message": "Recommended",
|
||||
"description": "Recommended configuration badge"
|
||||
},
|
||||
"alternativeLabel": {
|
||||
"message": "Alternative",
|
||||
"description": "Alternative configuration badge"
|
||||
}
|
||||
}
|
||||
|
@@ -442,5 +442,61 @@
|
||||
"pagesUnit": {
|
||||
"message": "页",
|
||||
"description": "页面计数单位"
|
||||
},
|
||||
"copiedButton": {
|
||||
"message": "✅ 已复制!",
|
||||
"description": "配置复制按钮文本"
|
||||
},
|
||||
"copyFailedButton": {
|
||||
"message": "❌ 复制失败",
|
||||
"description": "配置复制失败按钮文本"
|
||||
},
|
||||
"recommendedLabel": {
|
||||
"message": "推荐",
|
||||
"description": "推荐配置标识"
|
||||
},
|
||||
"alternativeLabel": {
|
||||
"message": "备选",
|
||||
"description": "备选配置标识"
|
||||
},
|
||||
"remoteServerConfigLabel": {
|
||||
"message": "远程服务器配置",
|
||||
"description": "远程服务器设置的主要节标题"
|
||||
},
|
||||
"remoteServerStatusLabel": {
|
||||
"message": "远程服务器状态",
|
||||
"description": "远程服务器状态标签"
|
||||
},
|
||||
"remoteMcpServerConfigLabel": {
|
||||
"message": "远程 MCP 服务器配置",
|
||||
"description": "远程 MCP 服务器配置标签"
|
||||
},
|
||||
"serverEndpointLabel": {
|
||||
"message": "服务器端点",
|
||||
"description": "服务器端点标签"
|
||||
},
|
||||
"reconnectAttemptsLabel": {
|
||||
"message": "重连尝试",
|
||||
"description": "重连尝试次数标签"
|
||||
},
|
||||
"connectionTimeLabel": {
|
||||
"message": "连接时长",
|
||||
"description": "连接持续时间标签"
|
||||
},
|
||||
"remoteServerConnectedStatus": {
|
||||
"message": "已连接到远程服务器",
|
||||
"description": "远程服务器已连接状态"
|
||||
},
|
||||
"remoteServerConnectingStatus": {
|
||||
"message": "正在连接远程服务器...",
|
||||
"description": "远程服务器连接中状态"
|
||||
},
|
||||
"remoteServerDisconnectedStatus": {
|
||||
"message": "已断开远程服务器连接",
|
||||
"description": "远程服务器已断开状态"
|
||||
},
|
||||
"remoteServerErrorStatus": {
|
||||
"message": "远程服务器错误",
|
||||
"description": "远程服务器错误状态"
|
||||
}
|
||||
}
|
||||
|
@@ -17,11 +17,13 @@ export const ICONS = {
|
||||
// Timeouts and Delays (in milliseconds)
|
||||
export const TIMEOUTS = {
|
||||
DEFAULT_WAIT: 1000,
|
||||
NETWORK_CAPTURE_MAX: 30000,
|
||||
NETWORK_CAPTURE_IDLE: 3000,
|
||||
NETWORK_CAPTURE_MAX: 60000, // Increased from 30000
|
||||
NETWORK_CAPTURE_IDLE: 5000, // Increased from 3000
|
||||
SCREENSHOT_DELAY: 100,
|
||||
KEYBOARD_DELAY: 50,
|
||||
CLICK_DELAY: 100,
|
||||
REMOTE_SERVER_CONNECTION: 45000, // Increased from 30000ms to 45000ms for more reliable connections
|
||||
TOOL_EXECUTION: 60000, // New timeout for tool execution
|
||||
} as const;
|
||||
|
||||
// Limits and Thresholds
|
||||
|
35
app/chrome-extension/common/env-config.ts
Normal file
35
app/chrome-extension/common/env-config.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Environment Configuration
|
||||
* Centralized environment variable handling for Chrome Extension
|
||||
*/
|
||||
|
||||
// Get environment variables with fallbacks
|
||||
const REMOTE_SERVER_HOST = import.meta.env.VITE_REMOTE_SERVER_HOST || '127.0.0.1';
|
||||
const REMOTE_SERVER_PORT = import.meta.env.VITE_REMOTE_SERVER_PORT || '3001';
|
||||
|
||||
// Debug logging for environment variables
|
||||
console.log('Environment Config Loaded:', {
|
||||
VITE_REMOTE_SERVER_HOST: import.meta.env.VITE_REMOTE_SERVER_HOST,
|
||||
VITE_REMOTE_SERVER_PORT: import.meta.env.VITE_REMOTE_SERVER_PORT,
|
||||
REMOTE_SERVER_HOST,
|
||||
REMOTE_SERVER_PORT,
|
||||
});
|
||||
|
||||
// Remote Server Configuration
|
||||
export const REMOTE_SERVER_CONFIG = {
|
||||
HOST: REMOTE_SERVER_HOST,
|
||||
PORT: REMOTE_SERVER_PORT,
|
||||
WS_URL: `ws://${REMOTE_SERVER_HOST}:${REMOTE_SERVER_PORT}/chrome`,
|
||||
HTTP_URL: `http://${REMOTE_SERVER_HOST}:${REMOTE_SERVER_PORT}/mcp`,
|
||||
} as const;
|
||||
|
||||
// Default connection settings
|
||||
export const DEFAULT_CONNECTION_CONFIG = {
|
||||
serverUrl: REMOTE_SERVER_CONFIG.WS_URL,
|
||||
reconnectInterval: 3000, // Reduced from 5000ms to 3000ms for faster reconnection
|
||||
maxReconnectAttempts: 999999, // Effectively unlimited for persistent connections
|
||||
} as const;
|
||||
|
||||
// Export individual values for backward compatibility
|
||||
export const DEFAULT_SERVER_URL = REMOTE_SERVER_CONFIG.WS_URL;
|
||||
export const DEFAULT_HTTP_URL = REMOTE_SERVER_CONFIG.HTTP_URL;
|
@@ -22,3 +22,15 @@ export const createErrorResponse = (
|
||||
isError: true,
|
||||
};
|
||||
};
|
||||
|
||||
export const createSuccessResponse = (data: any): ToolResult => {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: typeof data === 'string' ? data : JSON.stringify(data, null, 2),
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
};
|
||||
};
|
||||
|
@@ -1,38 +1,369 @@
|
||||
import { initNativeHostListener } from './native-host';
|
||||
import {
|
||||
initSemanticSimilarityListener,
|
||||
initializeSemanticEngineIfCached,
|
||||
} from './semantic-similarity';
|
||||
// Native messaging removed - using remote server only
|
||||
// import { initNativeHostListener } from './native-host';
|
||||
// Temporarily disable semantic similarity to focus on connection issues
|
||||
// import {
|
||||
// initSemanticSimilarityListener,
|
||||
// initializeSemanticEngineIfCached,
|
||||
// } from './semantic-similarity';
|
||||
import { initStorageManagerListener } from './storage-manager';
|
||||
import { cleanupModelCache } from '@/utils/semantic-similarity-engine';
|
||||
import { RemoteServerClient } from '@/utils/remote-server-client';
|
||||
import { DEFAULT_CONNECTION_CONFIG } from '@/common/env-config';
|
||||
import { handleCallTool } from './tools';
|
||||
|
||||
// Global remote server client instance
|
||||
let remoteServerClient: RemoteServerClient | null = null;
|
||||
|
||||
/**
|
||||
* Background script entry point
|
||||
* Initializes all background services and listeners
|
||||
*/
|
||||
export default defineBackground(() => {
|
||||
// Initialize core services
|
||||
initNativeHostListener();
|
||||
initSemanticSimilarityListener();
|
||||
// Initialize remote server client first (prioritize over native messaging)
|
||||
initRemoteServerClient();
|
||||
|
||||
// Initialize core services (native messaging removed)
|
||||
// initNativeHostListener();
|
||||
// initSemanticSimilarityListener();
|
||||
initStorageManagerListener();
|
||||
|
||||
// Initialize browser event listeners for connection persistence
|
||||
initBrowserEventListeners();
|
||||
|
||||
// Conditionally initialize semantic similarity engine if model cache exists
|
||||
initializeSemanticEngineIfCached()
|
||||
.then((initialized) => {
|
||||
if (initialized) {
|
||||
console.log('Background: Semantic similarity engine initialized from cache');
|
||||
} else {
|
||||
console.log(
|
||||
'Background: Semantic similarity engine initialization skipped (no cache found)',
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('Background: Failed to conditionally initialize semantic engine:', error);
|
||||
});
|
||||
// initializeSemanticEngineIfCached()
|
||||
// .then((initialized) => {
|
||||
// if (initialized) {
|
||||
// console.log('Background: Semantic similarity engine initialized from cache');
|
||||
// } else {
|
||||
// console.log(
|
||||
// 'Background: Semantic similarity engine initialization skipped (no cache found)',
|
||||
// );
|
||||
// }
|
||||
// })
|
||||
// .catch((error) => {
|
||||
// console.warn('Background: Failed to conditionally initialize semantic engine:', error);
|
||||
// });
|
||||
|
||||
// Initial cleanup on startup
|
||||
cleanupModelCache().catch((error) => {
|
||||
console.warn('Background: Initial cache cleanup failed:', error);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Initialize remote server client (without auto-connecting)
|
||||
*/
|
||||
function initRemoteServerClient() {
|
||||
try {
|
||||
remoteServerClient = new RemoteServerClient({
|
||||
serverUrl: DEFAULT_CONNECTION_CONFIG.serverUrl,
|
||||
reconnectInterval: DEFAULT_CONNECTION_CONFIG.reconnectInterval,
|
||||
maxReconnectAttempts: 50, // Increased for better reliability
|
||||
});
|
||||
|
||||
console.log('Background: Remote server client initialized (not connected)');
|
||||
console.log('Background: Use popup to manually connect to remote server');
|
||||
} catch (error) {
|
||||
console.error('Background: Failed to initialize remote server client:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the remote server client instance
|
||||
*/
|
||||
export function getRemoteServerClient(): RemoteServerClient | null {
|
||||
return remoteServerClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize browser event listeners for connection persistence
|
||||
*/
|
||||
function initBrowserEventListeners() {
|
||||
// Listen for browser startup events
|
||||
chrome.runtime.onStartup.addListener(() => {
|
||||
console.log('Background: Browser startup detected. Manual connection required via popup.');
|
||||
if (remoteServerClient) {
|
||||
console.log('Background: Remote server client ready for manual connection');
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for extension installation/update events
|
||||
chrome.runtime.onInstalled.addListener((details) => {
|
||||
console.log('Background: Extension installed/updated:', details.reason);
|
||||
if (details.reason === 'update') {
|
||||
console.log('Background: Extension updated, manual connection required');
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for browser suspension/resume events (Chrome specific)
|
||||
if (chrome.runtime.onSuspend) {
|
||||
chrome.runtime.onSuspend.addListener(() => {
|
||||
console.log('Background: Browser suspending, connection state saved');
|
||||
// Connection state is automatically saved when connected
|
||||
});
|
||||
}
|
||||
|
||||
if (chrome.runtime.onSuspendCanceled) {
|
||||
chrome.runtime.onSuspendCanceled.addListener(() => {
|
||||
console.log('Background: Browser suspend canceled, maintaining connection');
|
||||
});
|
||||
}
|
||||
|
||||
// Monitor tab events to ensure connection persists across tab operations
|
||||
chrome.tabs.onActivated.addListener((activeInfo) => {
|
||||
// Connection should persist regardless of tab switches
|
||||
if (remoteServerClient && remoteServerClient.isConnected()) {
|
||||
console.log(`Background: Tab switched to ${activeInfo.tabId}, connection maintained`);
|
||||
}
|
||||
});
|
||||
|
||||
// Monitor window events
|
||||
chrome.windows.onFocusChanged.addListener((windowId) => {
|
||||
// Connection should persist regardless of window focus changes
|
||||
if (
|
||||
remoteServerClient &&
|
||||
remoteServerClient.isConnected() &&
|
||||
windowId !== chrome.windows.WINDOW_ID_NONE
|
||||
) {
|
||||
console.log(`Background: Window focus changed to ${windowId}, connection maintained`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Background: Browser event listeners initialized for connection persistence');
|
||||
|
||||
// Start periodic connection health check
|
||||
startConnectionHealthCheck();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start periodic connection health check to maintain persistent connections
|
||||
*/
|
||||
function startConnectionHealthCheck() {
|
||||
// Check connection health every 5 minutes (for monitoring only, no auto-reconnection)
|
||||
setInterval(
|
||||
() => {
|
||||
if (remoteServerClient) {
|
||||
const isConnected = remoteServerClient.isConnected();
|
||||
console.log(`Background: Connection health check - Connected: ${isConnected}`);
|
||||
|
||||
if (!isConnected) {
|
||||
console.log('Background: Connection lost. Use popup to manually reconnect.');
|
||||
// No automatic reconnection - user must manually reconnect via popup
|
||||
}
|
||||
}
|
||||
},
|
||||
5 * 60 * 1000,
|
||||
); // 5 minutes
|
||||
|
||||
console.log(
|
||||
'Background: Connection health check started (monitoring only, no auto-reconnection)',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle messages from popup for remote server control
|
||||
*/
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message.type === 'getRemoteServerStatus') {
|
||||
const status = remoteServerClient?.getStatus() || {
|
||||
connected: false,
|
||||
connecting: false,
|
||||
reconnectAttempts: 0,
|
||||
connectionTime: undefined,
|
||||
serverUrl: DEFAULT_CONNECTION_CONFIG.serverUrl,
|
||||
};
|
||||
sendResponse(status);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === 'connectRemoteServer') {
|
||||
if (!remoteServerClient) {
|
||||
sendResponse({ success: false, error: 'Remote server client not initialized' });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (remoteServerClient.isConnected()) {
|
||||
sendResponse({ success: true, message: 'Already connected' });
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log('Background: Attempting to connect to remote server...');
|
||||
remoteServerClient
|
||||
.connect()
|
||||
.then(() => {
|
||||
console.log('Background: Successfully connected to remote server');
|
||||
sendResponse({ success: true });
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Background: Failed to connect to remote server:', error);
|
||||
sendResponse({ success: false, error: error.message });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === 'disconnectRemoteServer') {
|
||||
if (!remoteServerClient) {
|
||||
sendResponse({ success: false, error: 'Remote server client not initialized' });
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log('Background: Disconnecting from remote server...');
|
||||
try {
|
||||
remoteServerClient.disconnect();
|
||||
console.log('Background: Successfully disconnected from remote server');
|
||||
sendResponse({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Background: Error during disconnect:', error);
|
||||
sendResponse({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Disconnect failed',
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === 'restoreRemoteConnection') {
|
||||
if (!remoteServerClient) {
|
||||
sendResponse({ success: false, error: 'Remote server client not initialized' });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (remoteServerClient.isConnected()) {
|
||||
sendResponse({ success: true, message: 'Already connected' });
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log('Background: Attempting to restore previous connection...');
|
||||
remoteServerClient
|
||||
.restoreConnectionFromState()
|
||||
.then((restored) => {
|
||||
if (restored) {
|
||||
console.log('Background: Successfully restored previous connection');
|
||||
sendResponse({ success: true });
|
||||
} else {
|
||||
console.log('Background: No previous connection to restore');
|
||||
sendResponse({ success: false, error: 'No previous connection found' });
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Background: Failed to restore previous connection:', error);
|
||||
sendResponse({ success: false, error: error.message });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === 'getCurrentUserId') {
|
||||
if (!remoteServerClient) {
|
||||
sendResponse({ success: false, error: 'Remote server client not initialized' });
|
||||
return true;
|
||||
}
|
||||
|
||||
remoteServerClient
|
||||
.getCurrentUserId()
|
||||
.then((userId) => {
|
||||
sendResponse({ success: true, userId });
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Background: Failed to get current user ID:', error);
|
||||
sendResponse({ success: false, error: error.message });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === 'callTool') {
|
||||
handleCallTool({ name: message.toolName, args: message.params })
|
||||
.then((result) => {
|
||||
sendResponse(result);
|
||||
})
|
||||
.catch((error) => {
|
||||
sendResponse({ error: error.message });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === 'injectUserIdHelper') {
|
||||
injectUserIdHelper(message.tabId)
|
||||
.then((result) => {
|
||||
sendResponse(result);
|
||||
})
|
||||
.catch((error) => {
|
||||
sendResponse({ success: false, error: error.message });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Inject user ID helper script into a specific tab
|
||||
*/
|
||||
async function injectUserIdHelper(tabId?: number): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
let targetTabId = tabId;
|
||||
|
||||
// If no tab ID provided, use the active tab
|
||||
if (!targetTabId) {
|
||||
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
if (!tabs[0]?.id) {
|
||||
throw new Error('No active tab found');
|
||||
}
|
||||
targetTabId = tabs[0].id;
|
||||
}
|
||||
|
||||
// Inject the user ID helper script
|
||||
await chrome.scripting.executeScript({
|
||||
target: { tabId: targetTabId },
|
||||
files: ['inject-scripts/user-id-helper.js'],
|
||||
});
|
||||
|
||||
// Get current user ID and inject it
|
||||
if (remoteServerClient) {
|
||||
const userId = await remoteServerClient.getCurrentUserId();
|
||||
if (userId) {
|
||||
// Inject the user ID into the page
|
||||
await chrome.scripting.executeScript({
|
||||
target: { tabId: targetTabId },
|
||||
func: (userId) => {
|
||||
// Make user ID available globally
|
||||
(window as any).chromeExtensionUserId = userId;
|
||||
|
||||
// Store in sessionStorage
|
||||
try {
|
||||
sessionStorage.setItem('chromeExtensionUserId', userId);
|
||||
} catch (e) {
|
||||
// Ignore storage errors
|
||||
}
|
||||
|
||||
// Dispatch event for pages waiting for user ID
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('chromeExtensionUserIdReady', {
|
||||
detail: { userId: userId },
|
||||
}),
|
||||
);
|
||||
|
||||
console.log('Chrome Extension User ID injected:', userId);
|
||||
},
|
||||
args: [userId],
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `User ID helper injected into tab ${targetTabId} with user ID: ${userId}`,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: true,
|
||||
message: `User ID helper injected into tab ${targetTabId} but no user ID available (not connected)`,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
success: true,
|
||||
message: `User ID helper injected into tab ${targetTabId} but remote server client not initialized`,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to inject user ID helper:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
@@ -2,18 +2,66 @@ import { createErrorResponse, ToolResult } from '@/common/tool-handler';
|
||||
import { BaseBrowserToolExecutor } from '../base-browser';
|
||||
import { TOOL_NAMES } from 'chrome-mcp-shared';
|
||||
|
||||
// Default window dimensions
|
||||
// Default window dimensions - optimized for automation tools
|
||||
const DEFAULT_WINDOW_WIDTH = 1280;
|
||||
const DEFAULT_WINDOW_HEIGHT = 720;
|
||||
|
||||
interface NavigateToolParams {
|
||||
url?: string;
|
||||
newWindow?: boolean;
|
||||
backgroundPage?: boolean;
|
||||
width?: number;
|
||||
height?: number;
|
||||
refresh?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to create automation-friendly background windows
|
||||
* Ensures proper dimensions and timing for web automation tools
|
||||
*/
|
||||
async function createAutomationFriendlyBackgroundWindow(
|
||||
url: string,
|
||||
width: number,
|
||||
height: number,
|
||||
): Promise<chrome.windows.Window | null> {
|
||||
try {
|
||||
console.log(`Creating automation-friendly background window: ${width}x${height} for ${url}`);
|
||||
|
||||
// Create window with optimal settings for automation
|
||||
const window = await chrome.windows.create({
|
||||
url: url,
|
||||
width: width,
|
||||
height: height,
|
||||
focused: false, // Don't steal focus from user
|
||||
state: chrome.windows.WindowState.NORMAL, // Start in normal state
|
||||
type: 'normal', // Normal window type for full automation compatibility
|
||||
// Ensure window is created with proper viewport
|
||||
left: 0, // Position consistently for automation
|
||||
top: 0,
|
||||
});
|
||||
|
||||
if (window && window.id !== undefined) {
|
||||
// Wait for window to be properly established
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
|
||||
// Verify window still exists and has correct dimensions
|
||||
const windowInfo = await chrome.windows.get(window.id);
|
||||
if (windowInfo && windowInfo.width === width && windowInfo.height === height) {
|
||||
console.log(`Background window ${window.id} established with correct dimensions`);
|
||||
return window;
|
||||
} else {
|
||||
console.warn(`Window ${window.id} dimensions may not be correct`);
|
||||
return window; // Return anyway, might still work
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Failed to create automation-friendly background window:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool for navigating to URLs in browser tabs or windows
|
||||
*/
|
||||
@@ -21,11 +69,26 @@ class NavigateTool extends BaseBrowserToolExecutor {
|
||||
name = TOOL_NAMES.BROWSER.NAVIGATE;
|
||||
|
||||
async execute(args: NavigateToolParams): Promise<ToolResult> {
|
||||
// Check if backgroundPage was explicitly provided, if not, check user settings
|
||||
let backgroundPage = args.backgroundPage;
|
||||
if (backgroundPage === undefined) {
|
||||
try {
|
||||
const result = await chrome.storage.local.get(['openUrlsInBackground']);
|
||||
// Default to true for background windows (changed from false to true)
|
||||
backgroundPage =
|
||||
result.openUrlsInBackground !== undefined ? result.openUrlsInBackground : true;
|
||||
console.log(`Using stored background page preference: ${backgroundPage}`);
|
||||
} catch (error) {
|
||||
console.warn('Failed to load background page preference, using default (true):', error);
|
||||
backgroundPage = true; // Default to background windows
|
||||
}
|
||||
}
|
||||
|
||||
const { newWindow = false, width, height, url, refresh = false } = args;
|
||||
|
||||
console.log(
|
||||
`Attempting to ${refresh ? 'refresh current tab' : `open URL: ${url}`} with options:`,
|
||||
args,
|
||||
{ ...args, backgroundPage },
|
||||
);
|
||||
|
||||
try {
|
||||
@@ -121,7 +184,83 @@ class NavigateTool extends BaseBrowserToolExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
// 2. If URL is not already open, decide how to open it based on options
|
||||
// 2. Handle background page option
|
||||
if (backgroundPage) {
|
||||
console.log(
|
||||
'Opening URL in background page using full-size window that will be minimized.',
|
||||
);
|
||||
|
||||
const windowWidth = typeof width === 'number' ? width : DEFAULT_WINDOW_WIDTH;
|
||||
const windowHeight = typeof height === 'number' ? height : DEFAULT_WINDOW_HEIGHT;
|
||||
|
||||
// Create automation-friendly background window
|
||||
const backgroundWindow = await createAutomationFriendlyBackgroundWindow(
|
||||
url!,
|
||||
windowWidth,
|
||||
windowHeight,
|
||||
);
|
||||
|
||||
if (backgroundWindow && backgroundWindow.id !== undefined) {
|
||||
console.log(
|
||||
`Background window created with ID: ${backgroundWindow.id}, dimensions: ${windowWidth}x${windowHeight}`,
|
||||
);
|
||||
|
||||
try {
|
||||
// Verify window still exists before minimizing
|
||||
const windowInfo = await chrome.windows.get(backgroundWindow.id);
|
||||
if (windowInfo) {
|
||||
console.log(
|
||||
`Minimizing window ${backgroundWindow.id} while preserving automation accessibility`,
|
||||
);
|
||||
|
||||
// Now minimize the window to keep it in background while maintaining automation accessibility
|
||||
await chrome.windows.update(backgroundWindow.id, {
|
||||
state: chrome.windows.WindowState.MINIMIZED,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`URL opened in background Window ID: ${backgroundWindow.id} (${windowWidth}x${windowHeight} then minimized)`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to minimize window ${backgroundWindow.id}:`, error);
|
||||
// Continue anyway as the window was created successfully
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
message:
|
||||
'Opened URL in background page (full-size window then minimized for automation compatibility)',
|
||||
windowId: backgroundWindow.id,
|
||||
width: windowWidth,
|
||||
height: windowHeight,
|
||||
tabs: backgroundWindow.tabs
|
||||
? backgroundWindow.tabs.map((tab) => ({
|
||||
tabId: tab.id,
|
||||
url: tab.url,
|
||||
}))
|
||||
: [],
|
||||
automationReady: true,
|
||||
minimized: true,
|
||||
dimensions: `${windowWidth}x${windowHeight}`,
|
||||
}),
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
};
|
||||
} else {
|
||||
console.error('Failed to create automation-friendly background window');
|
||||
return createErrorResponse(
|
||||
'Failed to create background window with proper automation compatibility',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. If URL is not already open, decide how to open it based on options
|
||||
const openInNewWindow = newWindow || typeof width === 'number' || typeof height === 'number';
|
||||
|
||||
if (openInNewWindow) {
|
||||
|
@@ -0,0 +1,184 @@
|
||||
import { BaseBrowserToolExecutor } from '../base-browser';
|
||||
import { createErrorResponse, createSuccessResponse } from '../../../../common/tool-handler';
|
||||
import { ERROR_MESSAGES } from '../../../../common/constants';
|
||||
|
||||
export class EnhancedSearchTool extends BaseBrowserToolExecutor {
|
||||
async chromeSearchGoogle(args: {
|
||||
query: string;
|
||||
openGoogle?: boolean;
|
||||
extractResults?: boolean;
|
||||
maxResults?: number;
|
||||
}) {
|
||||
const { query, openGoogle = true, extractResults = true, maxResults = 10 } = args;
|
||||
|
||||
try {
|
||||
// Step 1: Navigate to Google if requested
|
||||
if (openGoogle) {
|
||||
await this.navigateToGoogle();
|
||||
await this.sleep(3000); // Wait for page to load
|
||||
}
|
||||
|
||||
// Step 2: Find and fill search box
|
||||
const searchSuccess = await this.performGoogleSearch(query);
|
||||
if (!searchSuccess) {
|
||||
return createErrorResponse(
|
||||
'Failed to perform Google search - could not find or interact with search box',
|
||||
);
|
||||
}
|
||||
|
||||
// Step 3: Wait for results to load
|
||||
await this.sleep(3000);
|
||||
|
||||
// Step 4: Extract results if requested
|
||||
if (extractResults) {
|
||||
const results = await this.extractSearchResults(maxResults);
|
||||
return createSuccessResponse({
|
||||
query,
|
||||
searchCompleted: true,
|
||||
resultsExtracted: true,
|
||||
results,
|
||||
});
|
||||
}
|
||||
|
||||
return createSuccessResponse({
|
||||
query,
|
||||
searchCompleted: true,
|
||||
resultsExtracted: false,
|
||||
message: 'Google search completed successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
return createErrorResponse(
|
||||
`Error performing Google search: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async chromeSubmitForm(args: {
|
||||
formSelector?: string;
|
||||
inputSelector?: string;
|
||||
submitMethod?: 'enter' | 'button' | 'auto';
|
||||
}) {
|
||||
const { formSelector = 'form', inputSelector, submitMethod = 'auto' } = args;
|
||||
|
||||
try {
|
||||
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
if (!tabs[0]?.id) {
|
||||
return createErrorResponse(ERROR_MESSAGES.TAB_NOT_FOUND);
|
||||
}
|
||||
|
||||
const tabId = tabs[0].id;
|
||||
|
||||
// Inject form submission script
|
||||
await this.injectContentScript(tabId, ['inject-scripts/form-submit-helper.js']);
|
||||
|
||||
const result = await this.sendMessageToTab(tabId, {
|
||||
action: 'submitForm',
|
||||
formSelector,
|
||||
inputSelector,
|
||||
submitMethod,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
return createErrorResponse(result.error);
|
||||
}
|
||||
|
||||
return createSuccessResponse(result);
|
||||
} catch (error) {
|
||||
return createErrorResponse(
|
||||
`Error submitting form: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async navigateToGoogle(): Promise<void> {
|
||||
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
if (!tabs[0]?.id) {
|
||||
throw new Error('No active tab found');
|
||||
}
|
||||
|
||||
await chrome.tabs.update(tabs[0].id, { url: 'https://www.google.com' });
|
||||
}
|
||||
|
||||
private async performGoogleSearch(query: string): Promise<boolean> {
|
||||
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
if (!tabs[0]?.id) {
|
||||
throw new Error('No active tab found');
|
||||
}
|
||||
|
||||
const tabId = tabs[0].id;
|
||||
|
||||
// Enhanced search box selectors
|
||||
const searchSelectors = [
|
||||
'#APjFqb', // Main Google search box ID
|
||||
'textarea[name="q"]', // Google search textarea
|
||||
'input[name="q"]', // Google search input (fallback)
|
||||
'[role="combobox"]', // Role-based selector
|
||||
'.gLFyf', // Google search box class
|
||||
'textarea[aria-label*="Search"]', // Aria-label based
|
||||
'[title*="Search"]', // Title attribute
|
||||
'.gsfi', // Google search field input class
|
||||
'#lst-ib', // Alternative Google search ID
|
||||
'input[type="search"]', // Generic search input
|
||||
'textarea[role="combobox"]', // Textarea with combobox role
|
||||
];
|
||||
|
||||
// Inject search helper script
|
||||
await this.injectContentScript(tabId, ['inject-scripts/enhanced-search-helper.js']);
|
||||
|
||||
for (const selector of searchSelectors) {
|
||||
try {
|
||||
const result = await this.sendMessageToTab(tabId, {
|
||||
action: 'performGoogleSearch',
|
||||
selector,
|
||||
query,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug(`Search selector ${selector} failed:`, error);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async extractSearchResults(maxResults: number): Promise<any[]> {
|
||||
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
if (!tabs[0]?.id) {
|
||||
throw new Error('No active tab found');
|
||||
}
|
||||
|
||||
const tabId = tabs[0].id;
|
||||
|
||||
const result = await this.sendMessageToTab(tabId, {
|
||||
action: 'extractSearchResults',
|
||||
maxResults,
|
||||
});
|
||||
|
||||
return result.results || [];
|
||||
}
|
||||
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
|
||||
// Export tool instances
|
||||
export const searchGoogleTool = new (class extends EnhancedSearchTool {
|
||||
name = 'chrome_search_google';
|
||||
|
||||
async execute(args: any) {
|
||||
return await this.chromeSearchGoogle(args);
|
||||
}
|
||||
})();
|
||||
|
||||
export const submitFormTool = new (class extends EnhancedSearchTool {
|
||||
name = 'chrome_submit_form';
|
||||
|
||||
async execute(args: any) {
|
||||
return await this.chromeSubmitForm(args);
|
||||
}
|
||||
})();
|
@@ -12,3 +12,4 @@ export { historyTool } from './history';
|
||||
export { bookmarkSearchTool, bookmarkAddTool, bookmarkDeleteTool } from './bookmark';
|
||||
export { injectScriptTool, sendCommandToInjectScriptTool } from './inject-script';
|
||||
export { consoleTool } from './console';
|
||||
export { searchGoogleTool, submitFormTool } from './enhanced-search';
|
||||
|
@@ -134,10 +134,13 @@ class NetworkDebuggerStartTool extends BaseBrowserToolExecutor {
|
||||
}
|
||||
NetworkDebuggerStartTool.instance = this;
|
||||
|
||||
chrome.debugger.onEvent.addListener(this.handleDebuggerEvent.bind(this));
|
||||
chrome.debugger.onDetach.addListener(this.handleDebuggerDetach.bind(this));
|
||||
chrome.tabs.onRemoved.addListener(this.handleTabRemoved.bind(this));
|
||||
chrome.tabs.onCreated.addListener(this.handleTabCreated.bind(this));
|
||||
// Only add listeners if chrome APIs are available (not during build)
|
||||
if (typeof chrome !== 'undefined' && chrome.debugger?.onEvent) {
|
||||
chrome.debugger.onEvent.addListener(this.handleDebuggerEvent.bind(this));
|
||||
chrome.debugger.onDetach.addListener(this.handleDebuggerDetach.bind(this));
|
||||
chrome.tabs.onRemoved.addListener(this.handleTabRemoved.bind(this));
|
||||
chrome.tabs.onCreated.addListener(this.handleTabCreated.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
private handleTabRemoved(tabId: number) {
|
||||
|
@@ -3,7 +3,7 @@ import { BaseBrowserToolExecutor } from '../base-browser';
|
||||
import { TOOL_NAMES } from 'chrome-mcp-shared';
|
||||
import { TOOL_MESSAGE_TYPES } from '@/common/message-types';
|
||||
|
||||
const DEFAULT_NETWORK_REQUEST_TIMEOUT = 30000; // For sending a single request via content script
|
||||
const DEFAULT_NETWORK_REQUEST_TIMEOUT = 60000; // For sending a single request via content script - increased from 30000
|
||||
|
||||
interface NetworkRequestToolParams {
|
||||
url: string; // URL is always required
|
||||
|
@@ -17,15 +17,31 @@ export interface ToolCallParam {
|
||||
* Handle tool execution
|
||||
*/
|
||||
export const handleCallTool = async (param: ToolCallParam) => {
|
||||
console.log('🛠️ [Tool Handler] Executing tool:', {
|
||||
toolName: param.name,
|
||||
hasArgs: !!param.args,
|
||||
availableTools: Array.from(toolsMap.keys()),
|
||||
args: param.args,
|
||||
});
|
||||
|
||||
const tool = toolsMap.get(param.name);
|
||||
if (!tool) {
|
||||
console.error('🛠️ [Tool Handler] Tool not found:', param.name);
|
||||
return createErrorResponse(`Tool ${param.name} not found`);
|
||||
}
|
||||
|
||||
try {
|
||||
return await tool.execute(param.args);
|
||||
console.log('🛠️ [Tool Handler] Starting tool execution for:', param.name);
|
||||
const result = await tool.execute(param.args);
|
||||
console.log('🛠️ [Tool Handler] Tool execution completed:', {
|
||||
toolName: param.name,
|
||||
hasResult: !!result,
|
||||
isError: result?.isError,
|
||||
result,
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`Tool execution failed for ${param.name}:`, error);
|
||||
console.error(`🛠️ [Tool Handler] Tool execution failed for ${param.name}:`, error);
|
||||
return createErrorResponse(
|
||||
error instanceof Error ? error.message : ERROR_MESSAGES.TOOL_EXECUTION_FAILED,
|
||||
);
|
||||
|
@@ -1,4 +1,44 @@
|
||||
export default defineContentScript({
|
||||
matches: ['*://*.google.com/*'],
|
||||
main() {},
|
||||
matches: ['<all_urls>'],
|
||||
main() {
|
||||
// Content script is now properly configured for all URLs
|
||||
// The actual functionality is handled by dynamically injected scripts
|
||||
// This ensures the content script context is available for communication
|
||||
console.log('Chrome MCP Extension content script loaded');
|
||||
|
||||
// Make user ID available globally on any page
|
||||
setupUserIdAccess();
|
||||
},
|
||||
});
|
||||
|
||||
async function setupUserIdAccess() {
|
||||
try {
|
||||
// Get user ID from background script
|
||||
const response = await chrome.runtime.sendMessage({ type: 'getCurrentUserId' });
|
||||
|
||||
if (response && response.success && response.userId) {
|
||||
// Make user ID available globally on the page
|
||||
(window as any).chromeExtensionUserId = response.userId;
|
||||
|
||||
// Also store in a custom event for pages that need it
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('chromeExtensionUserIdReady', {
|
||||
detail: { userId: response.userId },
|
||||
}),
|
||||
);
|
||||
|
||||
// Store in sessionStorage for easy access
|
||||
try {
|
||||
sessionStorage.setItem('chromeExtensionUserId', response.userId);
|
||||
} catch (e) {
|
||||
// Ignore storage errors (some sites block this)
|
||||
}
|
||||
|
||||
console.log('Chrome Extension User ID available:', response.userId);
|
||||
} else {
|
||||
console.log('Chrome Extension: No user ID available (not connected to server)');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Chrome Extension: Failed to get user ID:', error);
|
||||
}
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
@@ -244,3 +244,348 @@ select:focus {
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Enhanced Connection Status Display */
|
||||
.connection-status-display {
|
||||
margin: var(--spacing-lg) 0;
|
||||
padding: var(--spacing-lg);
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
flex-shrink: 0;
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.status-icon.status-connected {
|
||||
background: var(--success-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-icon.status-connecting {
|
||||
background: var(--warning-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-icon.status-disconnected {
|
||||
background: var(--text-muted);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-icon.status-error {
|
||||
background: var(--error-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-details {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.status-text-primary {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.status-error {
|
||||
font-size: 14px;
|
||||
color: var(--error-color);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.status-info {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.status-info:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Loading Spinner */
|
||||
.loading-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: white;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Enhanced Connect Button States */
|
||||
.connect-button--connected {
|
||||
background: var(--success-color) !important;
|
||||
}
|
||||
|
||||
.connect-button--connected:hover:not(:disabled) {
|
||||
background: #38a169 !important;
|
||||
}
|
||||
|
||||
.connect-button--connecting {
|
||||
background: var(--warning-color) !important;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.connect-button--connecting::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
.connect-button--error {
|
||||
background: var(--error-color) !important;
|
||||
}
|
||||
|
||||
.connect-button--error:hover:not(:disabled) {
|
||||
background: #e53e3e !important;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
left: -100%;
|
||||
}
|
||||
100% {
|
||||
left: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Error Actions and Help */
|
||||
.error-actions {
|
||||
margin-top: var(--spacing-sm);
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.retry-button,
|
||||
.help-button {
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.retry-button:hover:not(:disabled) {
|
||||
background: var(--info-color);
|
||||
color: white;
|
||||
border-color: var(--info-color);
|
||||
}
|
||||
|
||||
.help-button:hover {
|
||||
background: var(--warning-color);
|
||||
color: white;
|
||||
border-color: var(--warning-color);
|
||||
}
|
||||
|
||||
.retry-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.connection-help {
|
||||
margin-top: var(--spacing-md);
|
||||
padding: var(--spacing-md);
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
animation: slideDown var(--transition-normal);
|
||||
}
|
||||
|
||||
.help-content h4 {
|
||||
margin: 0 0 var(--spacing-sm) 0;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.help-content ul {
|
||||
margin: 0 0 var(--spacing-md) 0;
|
||||
padding-left: var(--spacing-lg);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.help-content li {
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.close-help-button {
|
||||
padding: var(--spacing-xs) var(--spacing-md);
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.close-help-button:hover {
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
|
||||
/* Connection Settings */
|
||||
.connection-settings {
|
||||
margin: var(--spacing-md) 0;
|
||||
padding: var(--spacing-md);
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.setting-checkbox {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--primary-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Advanced Settings */
|
||||
.settings-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing-md);
|
||||
padding-bottom: var(--spacing-sm);
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.settings-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.settings-toggle {
|
||||
padding: var(--spacing-xs);
|
||||
background: none;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.settings-toggle:hover {
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.advanced-settings {
|
||||
margin-top: var(--spacing-md);
|
||||
padding-top: var(--spacing-md);
|
||||
border-top: 1px solid var(--border-light);
|
||||
animation: slideDown var(--transition-normal);
|
||||
}
|
||||
|
||||
.setting-group {
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.setting-label-block {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.setting-input {
|
||||
width: 100%;
|
||||
padding: var(--spacing-sm);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.setting-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.setting-input:invalid {
|
||||
border-color: var(--error-color);
|
||||
}
|
||||
|
||||
.setting-actions {
|
||||
margin-top: var(--spacing-md);
|
||||
padding-top: var(--spacing-md);
|
||||
border-top: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.reset-button {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background: var(--warning-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.reset-button:hover {
|
||||
background: #d69e2e;
|
||||
}
|
||||
|
560
app/chrome-extension/inject-scripts/enhanced-search-helper.js
Normal file
560
app/chrome-extension/inject-scripts/enhanced-search-helper.js
Normal file
@@ -0,0 +1,560 @@
|
||||
/* eslint-disable */
|
||||
// enhanced-search-helper.js
|
||||
// Enhanced search automation with multiple submission methods
|
||||
|
||||
if (window.__ENHANCED_SEARCH_HELPER_INITIALIZED__) {
|
||||
// Already initialized, skip
|
||||
} else {
|
||||
window.__ENHANCED_SEARCH_HELPER_INITIALIZED__ = true;
|
||||
|
||||
/**
|
||||
* Perform Google search with enhanced reliability
|
||||
* @param {string} selector - CSS selector for the search box
|
||||
* @param {string} query - Search query
|
||||
* @returns {Promise<Object>} - Result of the search operation
|
||||
*/
|
||||
async function performGoogleSearch(selector, query) {
|
||||
try {
|
||||
console.log(`🔍 Attempting Google search with selector: ${selector}, query: ${query}`);
|
||||
|
||||
// Find the search element
|
||||
const searchElement = document.querySelector(selector);
|
||||
if (!searchElement) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Search element with selector "${selector}" not found`,
|
||||
};
|
||||
}
|
||||
|
||||
// Focus and clear the search box
|
||||
searchElement.focus();
|
||||
await sleep(200);
|
||||
|
||||
// Clear existing content
|
||||
searchElement.select();
|
||||
await sleep(100);
|
||||
|
||||
// Fill the search box
|
||||
searchElement.value = query;
|
||||
|
||||
// Trigger input events to ensure the page recognizes the input
|
||||
searchElement.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
searchElement.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
|
||||
await sleep(500);
|
||||
|
||||
// Try multiple submission methods
|
||||
const submissionSuccess = await submitGoogleSearch(searchElement, query);
|
||||
|
||||
if (submissionSuccess) {
|
||||
console.log(`✅ Google search submitted successfully using selector: ${selector}`);
|
||||
return {
|
||||
success: true,
|
||||
selector,
|
||||
query,
|
||||
method: submissionSuccess.method,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
error: 'All submission methods failed',
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in performGoogleSearch:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: `Unexpected error: ${error.message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try multiple methods to submit Google search
|
||||
* @param {Element} searchElement - The search input element
|
||||
* @param {string} query - Search query
|
||||
* @returns {Promise<Object|null>} - Success result or null
|
||||
*/
|
||||
async function submitGoogleSearch(searchElement, query) {
|
||||
const methods = [
|
||||
{
|
||||
name: 'enter_key',
|
||||
action: async () => {
|
||||
console.log('🔄 Method 1: Trying Enter key');
|
||||
searchElement.focus();
|
||||
await sleep(200);
|
||||
|
||||
const enterEvent = new KeyboardEvent('keydown', {
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
keyCode: 13,
|
||||
which: 13,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
|
||||
searchElement.dispatchEvent(enterEvent);
|
||||
await sleep(1000);
|
||||
|
||||
// Check if search was successful
|
||||
if (await checkSearchResultsLoaded()) {
|
||||
return { method: 'enter_key' };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'search_button',
|
||||
action: async () => {
|
||||
console.log('🔄 Method 2: Trying search button');
|
||||
|
||||
const buttonSelectors = [
|
||||
'input[value*="Google Search"]',
|
||||
'button[aria-label*="Google Search"]',
|
||||
'input[type="submit"][value*="Google Search"]',
|
||||
'.gNO89b', // Google Search button class
|
||||
'center input[type="submit"]:first-of-type',
|
||||
'button[type="submit"]',
|
||||
'[role="button"][aria-label*="search"]',
|
||||
'.Tg7LZd',
|
||||
];
|
||||
|
||||
for (const buttonSelector of buttonSelectors) {
|
||||
try {
|
||||
const button = document.querySelector(buttonSelector);
|
||||
if (button) {
|
||||
button.click();
|
||||
await sleep(1000);
|
||||
|
||||
if (await checkSearchResultsLoaded()) {
|
||||
return { method: 'search_button', selector: buttonSelector };
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'form_submit',
|
||||
action: async () => {
|
||||
console.log('🔄 Method 3: Trying form submission');
|
||||
|
||||
const form = searchElement.closest('form');
|
||||
if (form) {
|
||||
form.submit();
|
||||
await sleep(1000);
|
||||
|
||||
if (await checkSearchResultsLoaded()) {
|
||||
return { method: 'form_submit' };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'double_enter',
|
||||
action: async () => {
|
||||
console.log('🔄 Method 4: Trying double Enter');
|
||||
searchElement.focus();
|
||||
await sleep(200);
|
||||
|
||||
// First Enter
|
||||
const enterEvent1 = new KeyboardEvent('keydown', {
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
keyCode: 13,
|
||||
which: 13,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
searchElement.dispatchEvent(enterEvent1);
|
||||
await sleep(300);
|
||||
|
||||
// Second Enter
|
||||
const enterEvent2 = new KeyboardEvent('keydown', {
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
keyCode: 13,
|
||||
which: 13,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
searchElement.dispatchEvent(enterEvent2);
|
||||
await sleep(1000);
|
||||
|
||||
if (await checkSearchResultsLoaded()) {
|
||||
return { method: 'double_enter' };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const method of methods) {
|
||||
try {
|
||||
const result = await method.action();
|
||||
if (result) {
|
||||
console.log(`✅ Submission method "${method.name}" successful`);
|
||||
return result;
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug(`Submission method "${method.name}" failed:`, error);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
console.warn('❌ All submission methods failed');
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Google search results have loaded
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async function checkSearchResultsLoaded() {
|
||||
const resultIndicators = [
|
||||
'#search', // Main search results container
|
||||
'#rso', // Results container
|
||||
'.g', // Individual result
|
||||
'.tF2Cxc', // Modern Google result container
|
||||
'#result-stats', // Search statistics
|
||||
'.yuRUbf', // Result link container
|
||||
];
|
||||
|
||||
for (const indicator of resultIndicators) {
|
||||
const element = document.querySelector(indicator);
|
||||
if (element && element.children.length > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract search results from the current page with intelligent selector discovery
|
||||
* @param {number} maxResults - Maximum number of results to extract
|
||||
* @returns {Promise<Object>} - Extracted results
|
||||
*/
|
||||
async function extractSearchResults(maxResults = 10) {
|
||||
try {
|
||||
console.log('🔍 Starting intelligent search result extraction...');
|
||||
const results = [];
|
||||
|
||||
// Try multiple selectors for Google search results
|
||||
const resultSelectors = [
|
||||
'.tF2Cxc', // Current Google search result container
|
||||
'.g', // Traditional Google search result
|
||||
'#rso .g', // Results container with .g class
|
||||
'.yuRUbf', // Google result link container
|
||||
'.rc', // Another Google result class
|
||||
];
|
||||
|
||||
let resultElements = [];
|
||||
let successfulSelector = null;
|
||||
|
||||
// First try standard selectors
|
||||
for (const selector of resultSelectors) {
|
||||
resultElements = document.querySelectorAll(selector);
|
||||
if (resultElements.length > 0) {
|
||||
successfulSelector = selector;
|
||||
console.log(`✅ Found results with standard selector: ${selector}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If standard selectors fail, try intelligent discovery
|
||||
if (resultElements.length === 0) {
|
||||
console.log('🧠 Standard selectors failed, trying intelligent discovery...');
|
||||
const discoveryResult = await discoverSearchResultElements();
|
||||
resultElements = discoveryResult.elements;
|
||||
successfulSelector = discoveryResult.selector;
|
||||
}
|
||||
|
||||
// Extract results from found elements
|
||||
for (let i = 0; i < Math.min(resultElements.length, maxResults); i++) {
|
||||
const element = resultElements[i];
|
||||
|
||||
try {
|
||||
const extractedResult = extractResultFromElement(element, i + 1);
|
||||
if (extractedResult) {
|
||||
results.push(extractedResult);
|
||||
}
|
||||
} catch (e) {
|
||||
console.debug(`Error extracting result ${i}:`, e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
results,
|
||||
totalFound: results.length,
|
||||
selectorUsed: successfulSelector,
|
||||
method: resultElements.length > 0 ? 'extraction' : 'none',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error extracting search results:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
results: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Intelligent discovery of search result elements
|
||||
* @returns {Object} - Object with elements array and successful selector
|
||||
*/
|
||||
async function discoverSearchResultElements() {
|
||||
console.log('🔬 Starting intelligent element discovery...');
|
||||
|
||||
// Intelligent selectors based on common patterns
|
||||
const intelligentSelectors = [
|
||||
// Modern Google patterns (2024+)
|
||||
'[data-ved] h3',
|
||||
'[data-ved]:has(h3)',
|
||||
'[data-ved]:has(a[href*="http"])',
|
||||
'[jscontroller]:has(h3)',
|
||||
'[jscontroller]:has(a[href*="http"])',
|
||||
|
||||
// Generic search result patterns
|
||||
'div[class*="result"]:has(h3)',
|
||||
'div[class*="search"]:has(h3)',
|
||||
'article:has(h3)',
|
||||
'li[class*="result"]:has(h3)',
|
||||
'[role="main"] div:has(h3)',
|
||||
|
||||
// Link-based patterns
|
||||
'a[href*="http"]:has(h3)',
|
||||
'div:has(h3):has(a[href*="http"])',
|
||||
|
||||
// Container patterns
|
||||
'div[class*="container"] > div:has(h3)',
|
||||
'div[id*="result"]:has(h3)',
|
||||
'div[id*="search"]:has(h3)',
|
||||
|
||||
// Semantic patterns
|
||||
'[role="article"]:has(h3)',
|
||||
'[role="listitem"]:has(h3)',
|
||||
'div[aria-label*="result"]:has(h3)',
|
||||
|
||||
// Fallback broad patterns
|
||||
'main div:has(h3)',
|
||||
'#main div:has(h3)',
|
||||
'.main div:has(h3)',
|
||||
'h3:has(+ div)',
|
||||
'div:has(h3)',
|
||||
];
|
||||
|
||||
for (const selector of intelligentSelectors) {
|
||||
try {
|
||||
const elements = document.querySelectorAll(selector);
|
||||
if (elements.length > 0) {
|
||||
// Validate that these look like search results
|
||||
const validElements = Array.from(elements).filter((el) =>
|
||||
validateSearchResultElement(el),
|
||||
);
|
||||
|
||||
if (validElements.length > 0) {
|
||||
console.log(
|
||||
`✅ Found ${validElements.length} results with intelligent selector: ${selector}`,
|
||||
);
|
||||
return {
|
||||
elements: validElements,
|
||||
selector: `intelligent-${selector}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.debug(`Intelligent selector failed: ${selector}`, e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Final fallback - DOM structure analysis
|
||||
console.log('🔬 Trying DOM structure analysis...');
|
||||
return analyzeDOMForSearchResults();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that an element looks like a search result
|
||||
* @param {Element} element - Element to validate
|
||||
* @returns {boolean} - True if element looks like a search result
|
||||
*/
|
||||
function validateSearchResultElement(element) {
|
||||
try {
|
||||
// Check for common search result indicators
|
||||
const hasHeading = element.querySelector('h1, h2, h3, h4, h5, h6');
|
||||
const hasLink = element.querySelector('a[href*="http"]');
|
||||
const hasText = element.textContent && element.textContent.trim().length > 50;
|
||||
|
||||
// Must have at least heading and link, or substantial text
|
||||
return (hasHeading && hasLink) || hasText;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze DOM structure to find search results using heuristics
|
||||
* @returns {Object} - Object with elements array and successful selector
|
||||
*/
|
||||
function analyzeDOMForSearchResults() {
|
||||
console.log('🔬 Analyzing DOM structure for search results...');
|
||||
|
||||
try {
|
||||
// Look for containers with multiple links (likely search results)
|
||||
const heuristicSelectors = [
|
||||
'div:has(a[href*="http"]):has(h3)',
|
||||
'li:has(a[href*="http"]):has(h3)',
|
||||
'article:has(a[href*="http"])',
|
||||
'main > div:has(h3)',
|
||||
'#main > div:has(h3)',
|
||||
'[role="main"] > div:has(h3)',
|
||||
'div:has(h3):has(a[href*="http"])',
|
||||
'section:has(h3):has(a[href*="http"])',
|
||||
];
|
||||
|
||||
for (const selector of heuristicSelectors) {
|
||||
try {
|
||||
const elements = document.querySelectorAll(selector);
|
||||
if (elements.length > 0) {
|
||||
const validElements = Array.from(elements).filter((el) =>
|
||||
validateSearchResultElement(el),
|
||||
);
|
||||
|
||||
if (validElements.length > 0) {
|
||||
console.log(
|
||||
`✅ Found ${validElements.length} results with DOM analysis: ${selector}`,
|
||||
);
|
||||
return {
|
||||
elements: validElements,
|
||||
selector: `dom-analysis-${selector}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.debug(`DOM analysis selector failed: ${selector}`, e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Ultimate fallback - any elements with links
|
||||
const fallbackElements = document.querySelectorAll('a[href*="http"]');
|
||||
if (fallbackElements.length > 0) {
|
||||
console.log(`⚠️ Using fallback: found ${fallbackElements.length} link elements`);
|
||||
return {
|
||||
elements: Array.from(fallbackElements).slice(0, 10), // Limit to 10
|
||||
selector: 'fallback-links',
|
||||
};
|
||||
}
|
||||
|
||||
console.warn('❌ DOM analysis failed to find any search results');
|
||||
return {
|
||||
elements: [],
|
||||
selector: null,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error('Error in DOM analysis:', e);
|
||||
return {
|
||||
elements: [],
|
||||
selector: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract result data from a single element
|
||||
* @param {Element} element - Element to extract from
|
||||
* @param {number} index - Result index
|
||||
* @returns {Object|null} - Extracted result or null
|
||||
*/
|
||||
function extractResultFromElement(element, index) {
|
||||
try {
|
||||
// Try multiple patterns for title extraction
|
||||
const titleSelectors = ['h3', 'h2', 'h1', '.LC20lb', '.DKV0Md', 'a[href*="http"]'];
|
||||
let titleElement = null;
|
||||
|
||||
for (const selector of titleSelectors) {
|
||||
titleElement = element.querySelector(selector);
|
||||
if (titleElement) break;
|
||||
}
|
||||
|
||||
// Try multiple patterns for link extraction
|
||||
const linkElement =
|
||||
element.querySelector('a[href*="http"]') || (element.tagName === 'A' ? element : null);
|
||||
|
||||
// Try multiple patterns for snippet extraction
|
||||
const snippetSelectors = ['.VwiC3b', '.s', '.st', 'p', 'div:not(:has(h1,h2,h3,h4,h5,h6))'];
|
||||
let snippetElement = null;
|
||||
|
||||
for (const selector of snippetSelectors) {
|
||||
snippetElement = element.querySelector(selector);
|
||||
if (snippetElement && snippetElement.textContent.trim().length > 20) break;
|
||||
}
|
||||
|
||||
// Extract data
|
||||
const title = titleElement?.textContent?.trim() || 'No title found';
|
||||
const url = linkElement?.href || '';
|
||||
const snippet = snippetElement?.textContent?.trim() || '';
|
||||
|
||||
// Validate we have meaningful data
|
||||
if (title && title !== 'No title found' && url) {
|
||||
return {
|
||||
title,
|
||||
url,
|
||||
snippet,
|
||||
index,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
console.debug(`Error extracting from element:`, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep utility function
|
||||
* @param {number} ms - Milliseconds to sleep
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// Listen for messages from the extension
|
||||
chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {
|
||||
if (request.action === 'performGoogleSearch') {
|
||||
performGoogleSearch(request.selector, request.query)
|
||||
.then(sendResponse)
|
||||
.catch((error) => {
|
||||
sendResponse({
|
||||
success: false,
|
||||
error: `Unexpected error: ${error.message}`,
|
||||
});
|
||||
});
|
||||
return true; // Indicates async response
|
||||
} else if (request.action === 'extractSearchResults') {
|
||||
extractSearchResults(request.maxResults)
|
||||
.then(sendResponse)
|
||||
.catch((error) => {
|
||||
sendResponse({
|
||||
success: false,
|
||||
error: `Unexpected error: ${error.message}`,
|
||||
results: [],
|
||||
});
|
||||
});
|
||||
return true; // Indicates async response
|
||||
} else if (request.action === 'enhanced_search_ping') {
|
||||
sendResponse({ status: 'pong' });
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
277
app/chrome-extension/inject-scripts/form-submit-helper.js
Normal file
277
app/chrome-extension/inject-scripts/form-submit-helper.js
Normal file
@@ -0,0 +1,277 @@
|
||||
/* eslint-disable */
|
||||
// form-submit-helper.js
|
||||
// Enhanced form submission with multiple methods
|
||||
|
||||
if (window.__FORM_SUBMIT_HELPER_INITIALIZED__) {
|
||||
// Already initialized, skip
|
||||
} else {
|
||||
window.__FORM_SUBMIT_HELPER_INITIALIZED__ = true;
|
||||
|
||||
/**
|
||||
* Submit a form using multiple methods
|
||||
* @param {string} formSelector - CSS selector for the form
|
||||
* @param {string} inputSelector - CSS selector for input field to focus (optional)
|
||||
* @param {string} submitMethod - Preferred submission method
|
||||
* @returns {Promise<Object>} - Result of the submission
|
||||
*/
|
||||
async function submitForm(formSelector = 'form', inputSelector = null, submitMethod = 'auto') {
|
||||
try {
|
||||
console.log(`🔄 Attempting form submission with method: ${submitMethod}`);
|
||||
|
||||
// Find the form
|
||||
let form = null;
|
||||
if (formSelector) {
|
||||
form = document.querySelector(formSelector);
|
||||
}
|
||||
|
||||
// If no specific form found, try to find the form containing the input
|
||||
if (!form && inputSelector) {
|
||||
const input = document.querySelector(inputSelector);
|
||||
if (input) {
|
||||
form = input.closest('form');
|
||||
}
|
||||
}
|
||||
|
||||
// If still no form, try to find any form on the page
|
||||
if (!form) {
|
||||
form = document.querySelector('form');
|
||||
}
|
||||
|
||||
if (!form) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'No form found on the page',
|
||||
};
|
||||
}
|
||||
|
||||
// Focus input if specified
|
||||
if (inputSelector) {
|
||||
const input = document.querySelector(inputSelector);
|
||||
if (input) {
|
||||
input.focus();
|
||||
await sleep(200);
|
||||
}
|
||||
}
|
||||
|
||||
// Try submission based on method
|
||||
let result = null;
|
||||
|
||||
if (submitMethod === 'enter' || submitMethod === 'auto') {
|
||||
result = await tryEnterKeySubmission(form, inputSelector);
|
||||
if (result && result.success) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
if (submitMethod === 'button' || submitMethod === 'auto') {
|
||||
result = await tryButtonSubmission(form);
|
||||
if (result && result.success) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
if (submitMethod === 'auto') {
|
||||
result = await tryFormSubmission(form);
|
||||
if (result && result.success) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'All submission methods failed',
|
||||
attemptedMethods: submitMethod === 'auto' ? ['enter', 'button', 'form'] : [submitMethod],
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error in submitForm:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: `Unexpected error: ${error.message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try submitting form using Enter key
|
||||
* @param {Element} form - The form element
|
||||
* @param {string} inputSelector - Input selector to focus
|
||||
* @returns {Promise<Object|null>}
|
||||
*/
|
||||
async function tryEnterKeySubmission(form, inputSelector) {
|
||||
try {
|
||||
console.log('🔄 Trying Enter key submission');
|
||||
|
||||
let targetElement = null;
|
||||
|
||||
if (inputSelector) {
|
||||
targetElement = document.querySelector(inputSelector);
|
||||
}
|
||||
|
||||
if (!targetElement) {
|
||||
// Find the first input in the form
|
||||
targetElement = form.querySelector('input[type="text"], input[type="search"], textarea, input:not([type])');
|
||||
}
|
||||
|
||||
if (!targetElement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
targetElement.focus();
|
||||
await sleep(200);
|
||||
|
||||
const enterEvent = new KeyboardEvent('keydown', {
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
keyCode: 13,
|
||||
which: 13,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
});
|
||||
|
||||
targetElement.dispatchEvent(enterEvent);
|
||||
|
||||
// Also try keypress and keyup for compatibility
|
||||
const enterPress = new KeyboardEvent('keypress', {
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
keyCode: 13,
|
||||
which: 13,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
});
|
||||
targetElement.dispatchEvent(enterPress);
|
||||
|
||||
const enterUp = new KeyboardEvent('keyup', {
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
keyCode: 13,
|
||||
which: 13,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
});
|
||||
targetElement.dispatchEvent(enterUp);
|
||||
|
||||
await sleep(500);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
method: 'enter_key',
|
||||
element: targetElement.tagName.toLowerCase(),
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.debug('Enter key submission failed:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try submitting form by clicking submit button
|
||||
* @param {Element} form - The form element
|
||||
* @returns {Promise<Object|null>}
|
||||
*/
|
||||
async function tryButtonSubmission(form) {
|
||||
try {
|
||||
console.log('🔄 Trying button submission');
|
||||
|
||||
const buttonSelectors = [
|
||||
'input[type="submit"]',
|
||||
'button[type="submit"]',
|
||||
'button:not([type])', // Default button type is submit
|
||||
'input[value*="Search" i]',
|
||||
'input[value*="Submit" i]',
|
||||
'input[value*="Send" i]',
|
||||
'button:contains("Search")',
|
||||
'button:contains("Submit")',
|
||||
'button:contains("Send")',
|
||||
'.submit-btn',
|
||||
'.search-btn',
|
||||
'.btn-submit',
|
||||
'[role="button"][aria-label*="search" i]',
|
||||
'[role="button"][aria-label*="submit" i]'
|
||||
];
|
||||
|
||||
for (const selector of buttonSelectors) {
|
||||
try {
|
||||
let button = form.querySelector(selector);
|
||||
|
||||
// If not found in form, try the whole document
|
||||
if (!button) {
|
||||
button = document.querySelector(selector);
|
||||
}
|
||||
|
||||
if (button) {
|
||||
button.click();
|
||||
await sleep(300);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
method: 'button_click',
|
||||
selector: selector,
|
||||
element: button.tagName.toLowerCase(),
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
} catch (error) {
|
||||
console.debug('Button submission failed:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try submitting form using form.submit()
|
||||
* @param {Element} form - The form element
|
||||
* @returns {Promise<Object|null>}
|
||||
*/
|
||||
async function tryFormSubmission(form) {
|
||||
try {
|
||||
console.log('🔄 Trying form.submit()');
|
||||
|
||||
form.submit();
|
||||
await sleep(300);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
method: 'form_submit',
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.debug('Form submission failed:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep utility function
|
||||
* @param {number} ms - Milliseconds to sleep
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// Listen for messages from the extension
|
||||
chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {
|
||||
if (request.action === 'submitForm') {
|
||||
submitForm(request.formSelector, request.inputSelector, request.submitMethod)
|
||||
.then(sendResponse)
|
||||
.catch((error) => {
|
||||
sendResponse({
|
||||
success: false,
|
||||
error: `Unexpected error: ${error.message}`,
|
||||
});
|
||||
});
|
||||
return true; // Indicates async response
|
||||
} else if (request.action === 'form_submit_ping') {
|
||||
sendResponse({ status: 'pong' });
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
147
app/chrome-extension/inject-scripts/user-id-helper.js
Normal file
147
app/chrome-extension/inject-scripts/user-id-helper.js
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Chrome Extension User ID Helper
|
||||
* This script provides easy access to the Chrome extension user ID in any web page
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Namespace for Chrome extension user ID functionality
|
||||
window.ChromeExtensionUserID = {
|
||||
// Current user ID (will be populated when available)
|
||||
userId: null,
|
||||
|
||||
// Callbacks to execute when user ID becomes available
|
||||
callbacks: [],
|
||||
|
||||
/**
|
||||
* Get the current user ID
|
||||
* @returns {Promise<string|null>} The user ID or null if not available
|
||||
*/
|
||||
async getUserId() {
|
||||
// If already available, return immediately
|
||||
if (this.userId) {
|
||||
return this.userId;
|
||||
}
|
||||
|
||||
// Try to get from sessionStorage first
|
||||
try {
|
||||
const storedUserId = sessionStorage.getItem('chromeExtensionUserId');
|
||||
if (storedUserId) {
|
||||
this.userId = storedUserId;
|
||||
return storedUserId;
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore storage errors
|
||||
}
|
||||
|
||||
// Try to get from global window variable
|
||||
if (window.chromeExtensionUserId) {
|
||||
this.userId = window.chromeExtensionUserId;
|
||||
return this.userId;
|
||||
}
|
||||
|
||||
// Request from content script
|
||||
return new Promise((resolve) => {
|
||||
// Set up listener for the custom event
|
||||
const listener = (event) => {
|
||||
if (event.detail && event.detail.userId) {
|
||||
this.userId = event.detail.userId;
|
||||
window.removeEventListener('chromeExtensionUserIdReady', listener);
|
||||
resolve(this.userId);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('chromeExtensionUserIdReady', listener);
|
||||
|
||||
// Also check if it's already available
|
||||
setTimeout(() => {
|
||||
if (window.chromeExtensionUserId) {
|
||||
this.userId = window.chromeExtensionUserId;
|
||||
window.removeEventListener('chromeExtensionUserIdReady', listener);
|
||||
resolve(this.userId);
|
||||
} else {
|
||||
// Timeout after 5 seconds
|
||||
setTimeout(() => {
|
||||
window.removeEventListener('chromeExtensionUserIdReady', listener);
|
||||
resolve(null);
|
||||
}, 5000);
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Execute callback when user ID becomes available
|
||||
* @param {Function} callback - Function to execute with user ID
|
||||
*/
|
||||
onUserIdReady(callback) {
|
||||
if (this.userId) {
|
||||
// Execute immediately if already available
|
||||
callback(this.userId);
|
||||
} else {
|
||||
// Store callback for later execution
|
||||
this.callbacks.push(callback);
|
||||
|
||||
// Try to get user ID
|
||||
this.getUserId().then((userId) => {
|
||||
if (userId) {
|
||||
// Execute all pending callbacks
|
||||
this.callbacks.forEach(cb => cb(userId));
|
||||
this.callbacks = [];
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if user ID is available
|
||||
* @returns {boolean} True if user ID is available
|
||||
*/
|
||||
isAvailable() {
|
||||
return this.userId !== null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get user ID synchronously (only if already loaded)
|
||||
* @returns {string|null} The user ID or null if not loaded
|
||||
*/
|
||||
getUserIdSync() {
|
||||
return this.userId || window.chromeExtensionUserId || null;
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-initialize when script loads
|
||||
window.ChromeExtensionUserID.getUserId().then((userId) => {
|
||||
if (userId) {
|
||||
console.log('Chrome Extension User ID Helper: User ID loaded:', userId);
|
||||
} else {
|
||||
console.log('Chrome Extension User ID Helper: No user ID available');
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for the custom event in case it comes later
|
||||
window.addEventListener('chromeExtensionUserIdReady', (event) => {
|
||||
if (event.detail && event.detail.userId) {
|
||||
window.ChromeExtensionUserID.userId = event.detail.userId;
|
||||
console.log('Chrome Extension User ID Helper: User ID received via event:', event.detail.userId);
|
||||
|
||||
// Execute any pending callbacks
|
||||
window.ChromeExtensionUserID.callbacks.forEach(callback => callback(event.detail.userId));
|
||||
window.ChromeExtensionUserID.callbacks = [];
|
||||
}
|
||||
});
|
||||
|
||||
})();
|
||||
|
||||
// Also provide a simple global function for easy access
|
||||
window.getChromeExtensionUserId = function() {
|
||||
return window.ChromeExtensionUserID.getUserId();
|
||||
};
|
||||
|
||||
// Provide a synchronous version
|
||||
window.getChromeExtensionUserIdSync = function() {
|
||||
return window.ChromeExtensionUserID.getUserIdSync();
|
||||
};
|
||||
|
||||
console.log('Chrome Extension User ID Helper loaded. Use window.getChromeExtensionUserId() or window.ChromeExtensionUserID.getUserId()');
|
@@ -8,7 +8,8 @@
|
||||
"scripts": {
|
||||
"dev": "wxt",
|
||||
"dev:firefox": "wxt -b firefox",
|
||||
"build": "wxt build",
|
||||
"build": "wxt build && node -e \"const fs = require('fs'); const path = require('path'); const src = path.join('.output', 'chrome-mv3', '_locales', 'en', 'messages.json'); const dest = path.join('.output', 'chrome-mv3', '_locales', 'messages.json'); if (fs.existsSync(src)) { fs.copyFileSync(src, dest); console.log('Copied default locale file'); }\"",
|
||||
"build:basic": "wxt build",
|
||||
"build:firefox": "wxt build -b firefox",
|
||||
"zip": "wxt zip",
|
||||
"zip:firefox": "wxt zip -b firefox",
|
||||
|
@@ -40,6 +40,7 @@ const fallbackMessages: Record<string, string> = {
|
||||
connectionPortLabel: 'Connection Port',
|
||||
refreshStatusButton: 'Refresh Status',
|
||||
copyConfigButton: 'Copy Configuration',
|
||||
copyUserIdButton: 'Copy User ID',
|
||||
|
||||
// Action buttons
|
||||
retryButton: 'Retry',
|
||||
|
1283
app/chrome-extension/utils/remote-server-client.ts
Normal file
1283
app/chrome-extension/utils/remote-server-client.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -28,21 +28,19 @@ export default defineConfig({
|
||||
manifest: {
|
||||
// Use environment variable for the key, fallback to undefined if not set
|
||||
key: CHROME_EXTENSION_KEY,
|
||||
default_locale: 'zh_CN',
|
||||
default_locale: 'en',
|
||||
name: '__MSG_extensionName__',
|
||||
description: '__MSG_extensionDescription__',
|
||||
permissions: [
|
||||
'nativeMessaging',
|
||||
'tabs',
|
||||
'activeTab',
|
||||
'storage',
|
||||
'scripting',
|
||||
'downloads',
|
||||
'webRequest',
|
||||
'debugger',
|
||||
'webRequest',
|
||||
'history',
|
||||
'bookmarks',
|
||||
'offscreen',
|
||||
'storage',
|
||||
],
|
||||
host_permissions: ['<all_urls>'],
|
||||
web_accessible_resources: [
|
||||
|
@@ -12,9 +12,9 @@ export const NATIVE_SERVER_PORT = 56889;
|
||||
|
||||
// Timeout constants (in milliseconds)
|
||||
export const TIMEOUTS = {
|
||||
DEFAULT_REQUEST_TIMEOUT: 15000,
|
||||
EXTENSION_REQUEST_TIMEOUT: 20000,
|
||||
PROCESS_DATA_TIMEOUT: 20000,
|
||||
DEFAULT_REQUEST_TIMEOUT: 60000, // Increased from 15000
|
||||
EXTENSION_REQUEST_TIMEOUT: 90000, // Increased from 20000
|
||||
PROCESS_DATA_TIMEOUT: 90000, // Increased from 20000
|
||||
} as const;
|
||||
|
||||
// Server configuration
|
||||
|
284
app/remote-server/README.md
Normal file
284
app/remote-server/README.md
Normal file
@@ -0,0 +1,284 @@
|
||||
# MCP Chrome Remote Server
|
||||
|
||||
A remote server implementation for the MCP Chrome Bridge that allows external applications to control Chrome through **direct WebSocket connections**.
|
||||
|
||||
## 🚀 New Direct Connection Architecture
|
||||
|
||||
This server now supports **direct connections** from Chrome extensions, eliminating the need for native messaging hosts as intermediaries:
|
||||
|
||||
- **Cherry Studio** → **Remote Server** (via Streamable HTTP)
|
||||
- **Chrome Extension** → **Remote Server** (via WebSocket)
|
||||
- **No Native Server Required** for Chrome extension communication
|
||||
|
||||
### Benefits
|
||||
|
||||
- ✅ Eliminates 10-second timeout errors
|
||||
- ✅ Faster response times
|
||||
- ✅ Simplified architecture
|
||||
- ✅ Better reliability
|
||||
- ✅ Easier debugging
|
||||
|
||||
## Features
|
||||
|
||||
- **Remote Control**: Control Chrome browser remotely via WebSocket API
|
||||
- **MCP Protocol**: Implements Model Context Protocol for tool-based interactions
|
||||
- **HTTP Streaming**: Full support for MCP Streamable HTTP and SSE (Server-Sent Events)
|
||||
- **Real-time Communication**: WebSocket-based real-time communication with Chrome extensions
|
||||
- **RESTful Health Checks**: HTTP endpoints for monitoring server health
|
||||
- **Extensible Architecture**: Easy to add new Chrome automation tools
|
||||
- **Session Management**: Robust session handling for streaming connections
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Install Dependencies (from project root)
|
||||
|
||||
```bash
|
||||
# Install all workspace dependencies
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### 2. Build the Server
|
||||
|
||||
```bash
|
||||
# From project root
|
||||
npm run build:remote
|
||||
|
||||
# Or from this directory
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 3. Start the Server
|
||||
|
||||
```bash
|
||||
# From project root (recommended)
|
||||
npm run start:server
|
||||
|
||||
# Or from this directory
|
||||
npm run start:server
|
||||
```
|
||||
|
||||
The server will start on `http://localhost:3001` by default.
|
||||
|
||||
### 4. Verify Server is Running
|
||||
|
||||
You should see output like:
|
||||
|
||||
```
|
||||
🚀 MCP Remote Server started successfully!
|
||||
📡 Server running at: http://0.0.0.0:3001
|
||||
🔌 WebSocket endpoint: ws://0.0.0.0:3001/ws/mcp
|
||||
🔌 Chrome extension endpoint: ws://0.0.0.0:3001/chrome
|
||||
🌊 Streaming HTTP endpoint: http://0.0.0.0:3001/mcp
|
||||
📡 SSE endpoint: http://0.0.0.0:3001/sse
|
||||
```
|
||||
|
||||
### 5. Test the Connection
|
||||
|
||||
```bash
|
||||
# Test WebSocket connection
|
||||
node test-client.js
|
||||
|
||||
# Test streaming HTTP connection
|
||||
node test-tools-list.js
|
||||
|
||||
# Test SSE connection
|
||||
node test-sse-client.js
|
||||
|
||||
# Test simple health check
|
||||
node test-health.js
|
||||
```
|
||||
|
||||
## Available Scripts
|
||||
|
||||
- `npm run start:server` - Build and start the production server
|
||||
- `npm run start` - Start the server (requires pre-built dist/)
|
||||
- `npm run dev` - Start development server with auto-reload
|
||||
- `npm run build` - Build TypeScript to JavaScript
|
||||
- `npm run test` - Run tests
|
||||
- `npm run lint` - Run ESLint
|
||||
- `npm run format` - Format code with Prettier
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- `PORT` - Server port (default: 3001)
|
||||
- `HOST` - Server host (default: 0.0.0.0)
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### HTTP Endpoints
|
||||
|
||||
- `GET /health` - Health check endpoint
|
||||
|
||||
### Streaming HTTP Endpoints (MCP Protocol)
|
||||
|
||||
- `POST /mcp` - Send MCP messages (initialization, tool calls, etc.)
|
||||
- `GET /mcp` - Establish SSE stream for receiving responses (requires MCP-Session-ID header)
|
||||
- `DELETE /mcp` - Terminate MCP session (requires MCP-Session-ID header)
|
||||
|
||||
### SSE Endpoints
|
||||
|
||||
- `GET /sse` - Server-Sent Events endpoint for MCP communication
|
||||
- `POST /messages` - Send messages to SSE session (requires X-Session-ID header)
|
||||
|
||||
### WebSocket Endpoints
|
||||
|
||||
- `WS /ws/mcp` - MCP protocol WebSocket endpoint for Chrome control
|
||||
- `WS /chrome` - Chrome extension WebSocket endpoint
|
||||
|
||||
## Available Tools
|
||||
|
||||
The server provides the following Chrome automation tools:
|
||||
|
||||
1. **navigate_to_url** - Navigate to a specific URL
|
||||
2. **get_page_content** - Get page text content
|
||||
3. **click_element** - Click on elements using CSS selectors
|
||||
4. **fill_input** - Fill input fields with text
|
||||
5. **take_screenshot** - Capture page screenshots
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Streamable HTTP Connection (Recommended)
|
||||
|
||||
```javascript
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
const SERVER_URL = 'http://localhost:3001';
|
||||
const MCP_URL = `${SERVER_URL}/mcp`;
|
||||
|
||||
// Step 1: Initialize session
|
||||
const initResponse = await fetch(MCP_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json, text/event-stream',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: { tools: {} },
|
||||
clientInfo: { name: 'my-client', version: '1.0.0' },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const sessionId = initResponse.headers.get('mcp-session-id');
|
||||
|
||||
// Step 2: Call tools
|
||||
const toolResponse = await fetch(MCP_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json, text/event-stream',
|
||||
'MCP-Session-ID': sessionId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 2,
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'navigate_to_url',
|
||||
arguments: { url: 'https://example.com' },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await toolResponse.text(); // SSE format
|
||||
```
|
||||
|
||||
### WebSocket Connection
|
||||
|
||||
```javascript
|
||||
const ws = new WebSocket('ws://localhost:3001/ws/mcp');
|
||||
|
||||
// Navigate to a URL
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'navigate_to_url',
|
||||
arguments: { url: 'https://example.com' },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Get page content
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'get_page_content',
|
||||
arguments: {},
|
||||
},
|
||||
}),
|
||||
);
|
||||
```
|
||||
|
||||
## Streaming Capabilities
|
||||
|
||||
The MCP Remote Server now supports multiple connection types:
|
||||
|
||||
### 1. **Streamable HTTP (Recommended)**
|
||||
|
||||
- Full MCP protocol compliance
|
||||
- Session-based communication
|
||||
- Server-Sent Events for real-time responses
|
||||
- Stateful connections with session management
|
||||
- Compatible with MCP clients like CherryStudio
|
||||
|
||||
### 2. **Server-Sent Events (SSE)**
|
||||
|
||||
- Real-time streaming communication
|
||||
- Lightweight alternative to WebSockets
|
||||
- HTTP-based with automatic reconnection
|
||||
|
||||
### 3. **WebSocket (Legacy)**
|
||||
|
||||
- Real-time bidirectional communication
|
||||
- Backward compatibility with existing clients
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────┐ HTTP/SSE ┌──────────────────┐ WebSocket ┌─────────────────┐
|
||||
│ MCP Client │ ◄──────────────► │ Remote Server │ ◄─────────────────► │ Chrome Extension │
|
||||
│ (External App) │ WebSocket │ (This Server) │ │ │
|
||||
└─────────────────┘ └──────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── index.ts # Main server entry point
|
||||
├── server/
|
||||
│ ├── mcp-remote-server.ts # MCP protocol implementation
|
||||
│ └── chrome-tools.ts # Chrome automation tools
|
||||
└── types/ # TypeScript type definitions
|
||||
```
|
||||
|
||||
### Adding New Tools
|
||||
|
||||
1. Add the tool definition in `mcp-remote-server.ts`
|
||||
2. Implement the tool logic in `chrome-tools.ts`
|
||||
3. Update the Chrome extension to handle new actions
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Server won't start**: Check if port 3000 is available
|
||||
2. **Chrome extension not connecting**: Ensure the extension is installed and enabled
|
||||
3. **WebSocket connection fails**: Check firewall settings and CORS configuration
|
||||
|
||||
### Logs
|
||||
|
||||
The server uses structured logging with Pino. Check console output for detailed error messages and debugging information.
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see LICENSE file for details.
|
51
app/remote-server/package.json
Normal file
51
app/remote-server/package.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "mcp-chrome-remote-server",
|
||||
"version": "1.0.0",
|
||||
"description": "Remote MCP Chrome Bridge Server",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node dist/index.js",
|
||||
"start:server": "npm run build && npm run start",
|
||||
"start:custom": "npm run build && PORT=8080 HOST=localhost npm run start",
|
||||
"dev": "nodemon --watch src --ext ts,js,json --ignore dist/ --exec \"npm run build && npm run start\"",
|
||||
"build": "tsc",
|
||||
"test": "jest",
|
||||
"lint": "eslint 'src/**/*.{js,ts}'",
|
||||
"lint:fix": "eslint 'src/**/*.{js,ts}' --fix",
|
||||
"format": "prettier --write 'src/**/*.{js,ts,json}'"
|
||||
},
|
||||
"keywords": [
|
||||
"mcp",
|
||||
"chrome",
|
||||
"remote",
|
||||
"server"
|
||||
],
|
||||
"author": "hangye",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.0.1",
|
||||
"@fastify/websocket": "^11.0.1",
|
||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||
"chalk": "^5.4.1",
|
||||
"chrome-mcp-shared": "workspace:*",
|
||||
"eventsource": "^4.0.0",
|
||||
"fastify": "^5.3.2",
|
||||
"node-fetch": "^3.3.2",
|
||||
"pino": "^9.6.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"uuid": "^11.1.0",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^22.15.3",
|
||||
"@types/ws": "^8.5.13",
|
||||
"@typescript-eslint/parser": "^8.31.1",
|
||||
"eslint": "^9.26.0",
|
||||
"jest": "^29.7.0",
|
||||
"nodemon": "^3.1.10",
|
||||
"prettier": "^3.5.3",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
487
app/remote-server/src/index.ts
Normal file
487
app/remote-server/src/index.ts
Normal file
@@ -0,0 +1,487 @@
|
||||
import Fastify from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import websocket from '@fastify/websocket';
|
||||
import { pino } from 'pino';
|
||||
import chalk from 'chalk';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import { MCPRemoteServer } from './server/mcp-remote-server.js';
|
||||
|
||||
const logger = pino({
|
||||
level: 'info',
|
||||
});
|
||||
|
||||
async function startServer() {
|
||||
const fastify = Fastify({
|
||||
logger: true,
|
||||
});
|
||||
|
||||
// Register CORS
|
||||
await fastify.register(cors, {
|
||||
origin: true,
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
// Register WebSocket support
|
||||
await fastify.register(websocket);
|
||||
|
||||
// Create MCP Remote Server instance
|
||||
const mcpServer = new MCPRemoteServer(logger);
|
||||
|
||||
// Transport mapping for streaming connections
|
||||
const transportsMap: Map<string, StreamableHTTPServerTransport | SSEServerTransport> = new Map();
|
||||
|
||||
// Health check endpoint
|
||||
fastify.get('/health', async (request, reply) => {
|
||||
return { status: 'ok', timestamp: new Date().toISOString() };
|
||||
});
|
||||
|
||||
// SSE endpoint for streaming MCP communication
|
||||
fastify.get('/sse', async (request, reply) => {
|
||||
try {
|
||||
// Set SSE headers
|
||||
reply.raw.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'Cache-Control',
|
||||
});
|
||||
|
||||
// Create SSE transport
|
||||
const transport = new SSEServerTransport('/messages', reply.raw);
|
||||
transportsMap.set(transport.sessionId, transport);
|
||||
|
||||
reply.raw.on('close', () => {
|
||||
transportsMap.delete(transport.sessionId);
|
||||
logger.info(`SSE connection closed for session: ${transport.sessionId}`);
|
||||
});
|
||||
|
||||
// Start the transport first
|
||||
await transport.start();
|
||||
|
||||
// Connect the MCP server to this transport
|
||||
await mcpServer.connectTransport(transport);
|
||||
|
||||
// Hijack the reply to prevent Fastify from sending additional headers
|
||||
reply.hijack();
|
||||
} catch (error) {
|
||||
logger.error('Error setting up SSE connection:', error);
|
||||
if (!reply.sent && !reply.raw.headersSent) {
|
||||
reply.code(500).send({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// POST /messages: Handle SSE POST messages
|
||||
fastify.post('/messages', async (request, reply) => {
|
||||
const sessionId = request.headers['x-session-id'] as string | undefined;
|
||||
const transport = sessionId ? (transportsMap.get(sessionId) as SSEServerTransport) : undefined;
|
||||
|
||||
if (!transport) {
|
||||
reply.code(400).send({ error: 'Invalid session ID for SSE' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await transport.handlePostMessage(request.raw, reply.raw, request.body);
|
||||
} catch (error) {
|
||||
logger.error('Error handling SSE POST message:', error);
|
||||
if (!reply.sent) {
|
||||
reply.code(500).send({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// POST /mcp: Handle client-to-server messages for streamable HTTP
|
||||
fastify.post('/mcp', async (request, reply) => {
|
||||
// Extract session ID and user ID from headers for routing
|
||||
const sessionId = request.headers['mcp-session-id'] as string | undefined;
|
||||
const userId = request.headers['chrome-user-id'] as string | undefined;
|
||||
let transport: StreamableHTTPServerTransport | undefined = transportsMap.get(
|
||||
sessionId || '',
|
||||
) as StreamableHTTPServerTransport;
|
||||
|
||||
if (transport) {
|
||||
// Transport found, use existing one
|
||||
} else if (!sessionId && isInitializeRequest(request.body)) {
|
||||
// Create new session for initialization request
|
||||
const newSessionId = randomUUID();
|
||||
transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => newSessionId,
|
||||
onsessioninitialized: (initializedSessionId) => {
|
||||
if (transport && initializedSessionId === newSessionId) {
|
||||
transportsMap.set(initializedSessionId, transport);
|
||||
logger.info(`New streamable HTTP session initialized: ${initializedSessionId}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Connect the MCP server to this transport
|
||||
await mcpServer.connectTransport(transport);
|
||||
} else {
|
||||
reply.code(400).send({ error: 'Invalid session or missing initialization' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Set user context for routing if user ID is provided
|
||||
if (userId) {
|
||||
mcpServer.setUserContext(userId, sessionId);
|
||||
logger.info(
|
||||
`🎯 [MCP] User context set for request - User: ${userId}, Session: ${sessionId}`,
|
||||
);
|
||||
}
|
||||
|
||||
await transport.handleRequest(request.raw, reply.raw, request.body);
|
||||
if (!reply.sent) {
|
||||
reply.hijack(); // Prevent Fastify from automatically sending response
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error handling streamable HTTP POST request:', error);
|
||||
if (!reply.sent) {
|
||||
reply.code(500).send({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// GET /mcp: Handle SSE stream for streamable HTTP
|
||||
fastify.get('/mcp', async (request, reply) => {
|
||||
const sessionId = request.headers['mcp-session-id'] as string | undefined;
|
||||
const transport = sessionId
|
||||
? (transportsMap.get(sessionId) as StreamableHTTPServerTransport)
|
||||
: undefined;
|
||||
|
||||
if (!transport) {
|
||||
reply.code(400).send({ error: 'Invalid session ID' });
|
||||
return;
|
||||
}
|
||||
|
||||
reply.raw.setHeader('Content-Type', 'text/event-stream');
|
||||
reply.raw.setHeader('Cache-Control', 'no-cache');
|
||||
reply.raw.setHeader('Connection', 'keep-alive');
|
||||
reply.raw.setHeader('Access-Control-Allow-Origin', '*');
|
||||
reply.raw.flushHeaders();
|
||||
|
||||
try {
|
||||
await transport.handleRequest(request.raw, reply.raw);
|
||||
if (!reply.sent) {
|
||||
reply.hijack(); // Prevent Fastify from automatically sending response
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error handling streamable HTTP GET request:', error);
|
||||
if (!reply.raw.writableEnded) {
|
||||
reply.raw.end();
|
||||
}
|
||||
}
|
||||
|
||||
request.socket.on('close', () => {
|
||||
logger.info(`Streamable HTTP client disconnected for session: ${sessionId}`);
|
||||
});
|
||||
});
|
||||
|
||||
// DELETE /mcp: Handle session termination for streamable HTTP
|
||||
fastify.delete('/mcp', async (request, reply) => {
|
||||
const sessionId = request.headers['mcp-session-id'] as string | undefined;
|
||||
const transport = sessionId
|
||||
? (transportsMap.get(sessionId) as StreamableHTTPServerTransport)
|
||||
: undefined;
|
||||
|
||||
if (!transport) {
|
||||
reply.code(400).send({ error: 'Invalid session ID' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await transport.handleRequest(request.raw, reply.raw);
|
||||
if (sessionId) {
|
||||
transportsMap.delete(sessionId);
|
||||
logger.info(`Streamable HTTP session terminated: ${sessionId}`);
|
||||
}
|
||||
|
||||
if (!reply.sent) {
|
||||
reply.code(204).send();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error handling streamable HTTP DELETE request:', error);
|
||||
if (!reply.sent) {
|
||||
reply.code(500).send({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// WebSocket endpoint for MCP communication
|
||||
fastify.register(async function (fastify) {
|
||||
fastify.get('/ws/mcp', { websocket: true }, (connection: any, req) => {
|
||||
logger.info('New MCP WebSocket connection established');
|
||||
|
||||
// Set up ping/pong to keep connection alive
|
||||
const pingInterval = setInterval(() => {
|
||||
if (connection.readyState === connection.OPEN) {
|
||||
connection.ping();
|
||||
}
|
||||
}, 30000); // Ping every 30 seconds
|
||||
|
||||
connection.on('pong', () => {
|
||||
logger.debug('Received pong from MCP client');
|
||||
});
|
||||
|
||||
connection.on('message', async (message: any) => {
|
||||
try {
|
||||
const data = JSON.parse(message.toString());
|
||||
console.log('🔵 [MCP WebSocket] Received message:', JSON.stringify(data, null, 2));
|
||||
logger.info('🔵 [MCP WebSocket] Received message:', {
|
||||
method: data.method,
|
||||
id: data.id,
|
||||
params: data.params,
|
||||
fullMessage: data,
|
||||
});
|
||||
|
||||
// Handle MCP protocol messages with proper ID preservation
|
||||
let response;
|
||||
|
||||
if (data.method === 'tools/list') {
|
||||
try {
|
||||
const toolsResponse = await mcpServer.handleMessage(data);
|
||||
response = {
|
||||
jsonrpc: '2.0',
|
||||
id: data.id,
|
||||
result: toolsResponse,
|
||||
};
|
||||
} catch (error) {
|
||||
response = {
|
||||
jsonrpc: '2.0',
|
||||
id: data.id,
|
||||
error: {
|
||||
code: -32603,
|
||||
message: error instanceof Error ? error.message : 'Internal error',
|
||||
},
|
||||
};
|
||||
}
|
||||
} else if (data.method === 'tools/call') {
|
||||
try {
|
||||
const toolResponse = await mcpServer.handleMessage(data);
|
||||
response = {
|
||||
jsonrpc: '2.0',
|
||||
id: data.id,
|
||||
result: toolResponse,
|
||||
};
|
||||
} catch (error) {
|
||||
response = {
|
||||
jsonrpc: '2.0',
|
||||
id: data.id,
|
||||
error: {
|
||||
code: -32603,
|
||||
message: error instanceof Error ? error.message : 'Tool execution failed',
|
||||
},
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// Unknown method
|
||||
response = {
|
||||
jsonrpc: '2.0',
|
||||
id: data.id,
|
||||
error: {
|
||||
code: -32601,
|
||||
message: 'Method not found',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (response) {
|
||||
logger.info('🟢 [MCP WebSocket] Sending response:', {
|
||||
id: response.id,
|
||||
hasResult: !!response.result,
|
||||
hasError: !!response.error,
|
||||
fullResponse: response,
|
||||
});
|
||||
connection.send(JSON.stringify(response));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('🚨 [MCP WebSocket] Error handling MCP message:');
|
||||
console.error('Error message:', error instanceof Error ? error.message : String(error));
|
||||
console.error('Error stack:', error instanceof Error ? error.stack : undefined);
|
||||
console.error('Full error:', error);
|
||||
logger.error('🚨 [MCP WebSocket] Error handling MCP message:', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
errorType: typeof error,
|
||||
fullError: error,
|
||||
});
|
||||
// Send error response with proper MCP format
|
||||
const errorResponse = {
|
||||
jsonrpc: '2.0',
|
||||
id: null, // Use null if we can't parse the original ID
|
||||
error: {
|
||||
code: -32700,
|
||||
message: 'Parse error',
|
||||
},
|
||||
};
|
||||
connection.send(JSON.stringify(errorResponse));
|
||||
}
|
||||
});
|
||||
|
||||
connection.on('close', () => {
|
||||
logger.info('MCP WebSocket connection closed');
|
||||
clearInterval(pingInterval);
|
||||
});
|
||||
|
||||
connection.on('error', (error: any) => {
|
||||
logger.error('WebSocket error:', error);
|
||||
clearInterval(pingInterval);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// WebSocket endpoint for Chrome extension connections
|
||||
fastify.register(async function (fastify) {
|
||||
fastify.get('/chrome', { websocket: true }, (connection: any, req) => {
|
||||
logger.info('New Chrome extension WebSocket connection established');
|
||||
|
||||
// Set up ping/pong to keep Chrome extension connection alive
|
||||
const chromeExtensionPingInterval = setInterval(() => {
|
||||
if (connection.readyState === connection.OPEN) {
|
||||
connection.ping();
|
||||
}
|
||||
}, 30000); // Ping every 30 seconds
|
||||
|
||||
// Create a connection wrapper for the Chrome tools
|
||||
const connectionWrapper = {
|
||||
socket: connection,
|
||||
send: (data: string) => connection.send(data),
|
||||
on: (event: string, handler: Function) => connection.on(event, handler),
|
||||
off: (event: string, handler: Function) => connection.off(event, handler),
|
||||
get readyState() {
|
||||
// WebSocket states: 0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED
|
||||
return connection.readyState || 1; // Default to OPEN if not available
|
||||
},
|
||||
};
|
||||
|
||||
// Extract user information from connection headers or query params
|
||||
const userAgent = req.headers['user-agent'] || 'Unknown';
|
||||
const ipAddress = req.headers['x-forwarded-for'] || req.socket?.remoteAddress || 'Unknown';
|
||||
|
||||
// Initialize with temporary user ID (will be updated when Chrome extension sends connection_info)
|
||||
let currentUserId = `temp_user_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`;
|
||||
|
||||
// Register this connection with the Chrome tools with session management
|
||||
const sessionInfo = mcpServer.registerChromeExtension(connectionWrapper, currentUserId, {
|
||||
userAgent,
|
||||
ipAddress,
|
||||
connectedAt: new Date().toISOString(),
|
||||
connectionType: 'anonymous',
|
||||
});
|
||||
|
||||
logger.info('🟢 [Chrome Extension] Connection registered:', sessionInfo);
|
||||
|
||||
connection.on('message', async (message: any) => {
|
||||
try {
|
||||
const data = JSON.parse(message.toString());
|
||||
|
||||
// Handle connection info message
|
||||
if (data.type === 'connection_info') {
|
||||
logger.info('🔗 [Chrome Extension] Received connection info:', data);
|
||||
|
||||
// Update user ID if provided by Chrome extension
|
||||
if (data.userId && data.userId !== sessionInfo.userId) {
|
||||
logger.info(
|
||||
`🔄 [Chrome Extension] Updating user ID from ${sessionInfo.userId} to ${data.userId}`,
|
||||
);
|
||||
|
||||
// Update the session with the Chrome extension's user ID
|
||||
const updatedSessionInfo = mcpServer.updateChromeExtensionUserId(
|
||||
connectionWrapper,
|
||||
data.userId,
|
||||
);
|
||||
if (updatedSessionInfo) {
|
||||
// Update our local reference
|
||||
Object.assign(sessionInfo, updatedSessionInfo);
|
||||
logger.info(
|
||||
`✅ [Chrome Extension] User ID updated successfully: ${sessionInfo.userId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Send session info back to extension
|
||||
const sessionResponse = {
|
||||
type: 'session_info',
|
||||
sessionInfo: {
|
||||
userId: sessionInfo.userId,
|
||||
sessionId: sessionInfo.sessionId,
|
||||
connectionId: sessionInfo.connectionId,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
connection.send(JSON.stringify(sessionResponse));
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('🟡 [Chrome Extension] Received message:', {
|
||||
action: data.action,
|
||||
id: data.id,
|
||||
type: data.type,
|
||||
sessionId: sessionInfo.sessionId,
|
||||
userId: sessionInfo.userId,
|
||||
fullMessage: data,
|
||||
});
|
||||
|
||||
// Handle responses from Chrome extension
|
||||
mcpServer.handleChromeResponse(data);
|
||||
} catch (error) {
|
||||
logger.error('Error handling Chrome extension message:', error);
|
||||
}
|
||||
});
|
||||
|
||||
connection.on('close', () => {
|
||||
logger.info('Chrome extension WebSocket connection closed');
|
||||
clearInterval(chromeExtensionPingInterval);
|
||||
mcpServer.unregisterChromeExtension(connectionWrapper);
|
||||
});
|
||||
|
||||
connection.on('error', (error: any) => {
|
||||
logger.error('Chrome extension WebSocket error:', error);
|
||||
clearInterval(chromeExtensionPingInterval);
|
||||
mcpServer.unregisterChromeExtension(connectionWrapper);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Start the server
|
||||
const port = process.env.PORT ? parseInt(process.env.PORT) : 3001;
|
||||
const host = process.env.HOST || '0.0.0.0';
|
||||
|
||||
try {
|
||||
await fastify.listen({ port, host });
|
||||
console.log(chalk.green(`🚀 MCP Remote Server started successfully!`));
|
||||
console.log(chalk.blue(`📡 Server running at: http://${host}:${port}`));
|
||||
console.log(chalk.blue(`🌊 Streaming HTTP endpoint: http://${host}:${port}/mcp`));
|
||||
console.log(chalk.blue(`📡 SSE endpoint: http://${host}:${port}/sse`));
|
||||
console.log(chalk.blue(`🔌 WebSocket endpoint: ws://${host}:${port}/ws/mcp`));
|
||||
console.log(chalk.blue(`🔌 Chrome extension endpoint: ws://${host}:${port}/chrome`));
|
||||
console.log(chalk.yellow(`💡 Use 'npm run start:server' to start the server`));
|
||||
} catch (err) {
|
||||
console.error('Error starting server:', err);
|
||||
logger.error('Error starting server:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle graceful shutdown
|
||||
process.on('SIGINT', () => {
|
||||
console.log(chalk.yellow('\n🛑 Shutting down server...'));
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
console.log(chalk.yellow('\n🛑 Shutting down server...'));
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
startServer().catch((error) => {
|
||||
console.error('Failed to start server:', error);
|
||||
logger.error('Failed to start server:', error);
|
||||
process.exit(1);
|
||||
});
|
647
app/remote-server/src/server/chrome-tools.ts
Normal file
647
app/remote-server/src/server/chrome-tools.ts
Normal file
@@ -0,0 +1,647 @@
|
||||
import { Logger } from 'pino';
|
||||
import { TOOL_NAMES } from 'chrome-mcp-shared';
|
||||
import { SessionManager, ExtensionConnection } from './session-manager.js';
|
||||
import { ConnectionRouter, RouteResult } from './connection-router.js';
|
||||
import { LiveKitAgentManager } from './livekit-agent-manager.js';
|
||||
|
||||
export class ChromeTools {
|
||||
private logger: Logger;
|
||||
private sessionManager: SessionManager;
|
||||
private connectionRouter: ConnectionRouter;
|
||||
private liveKitAgentManager: LiveKitAgentManager;
|
||||
private currentUserId?: string;
|
||||
private currentSessionId?: string;
|
||||
|
||||
// Common URL mappings for natural language requests
|
||||
private urlMappings: Map<string, string> = new Map([
|
||||
['google', 'https://www.google.com'],
|
||||
['google.com', 'https://www.google.com'],
|
||||
['youtube', 'https://www.youtube.com'],
|
||||
['youtube.com', 'https://www.youtube.com'],
|
||||
['facebook', 'https://www.facebook.com'],
|
||||
['facebook.com', 'https://www.facebook.com'],
|
||||
['twitter', 'https://www.twitter.com'],
|
||||
['twitter.com', 'https://www.twitter.com'],
|
||||
['x.com', 'https://www.x.com'],
|
||||
['github', 'https://www.github.com'],
|
||||
['github.com', 'https://www.github.com'],
|
||||
['stackoverflow', 'https://www.stackoverflow.com'],
|
||||
['stackoverflow.com', 'https://www.stackoverflow.com'],
|
||||
['reddit', 'https://www.reddit.com'],
|
||||
['reddit.com', 'https://www.reddit.com'],
|
||||
['amazon', 'https://www.amazon.com'],
|
||||
['amazon.com', 'https://www.amazon.com'],
|
||||
['netflix', 'https://www.netflix.com'],
|
||||
['netflix.com', 'https://www.netflix.com'],
|
||||
['linkedin', 'https://www.linkedin.com'],
|
||||
['linkedin.com', 'https://www.linkedin.com'],
|
||||
['instagram', 'https://www.instagram.com'],
|
||||
['instagram.com', 'https://www.instagram.com'],
|
||||
]);
|
||||
|
||||
constructor(logger: Logger) {
|
||||
this.logger = logger;
|
||||
this.sessionManager = new SessionManager(logger);
|
||||
this.connectionRouter = new ConnectionRouter(logger, this.sessionManager);
|
||||
this.liveKitAgentManager = new LiveKitAgentManager(logger, this.sessionManager);
|
||||
}
|
||||
|
||||
// Register a Chrome extension connection with session management
|
||||
registerExtension(
|
||||
connection: any,
|
||||
userId?: string,
|
||||
metadata?: any,
|
||||
): { userId: string; sessionId: string; connectionId: string } {
|
||||
const result = this.sessionManager.registerExtensionConnection(connection, userId, metadata);
|
||||
this.logger.info(
|
||||
`🔗 Chrome extension connected - User: ${result.userId}, Session: ${result.sessionId}`,
|
||||
);
|
||||
|
||||
// Note: LiveKit agent is no longer started automatically on connection
|
||||
// Agents should be started manually when needed
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Unregister a Chrome extension connection
|
||||
unregisterExtension(connection: any): boolean {
|
||||
const result = this.sessionManager.unregisterExtensionConnection(connection);
|
||||
if (result) {
|
||||
this.logger.info('🔌 Chrome extension disconnected');
|
||||
// Note: LiveKit agent is no longer stopped automatically on disconnection
|
||||
// Agents should be managed manually when needed
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Update Chrome extension user ID
|
||||
updateExtensionUserId(connection: any, newUserId: string): any {
|
||||
const result = this.sessionManager.updateExtensionUserId(connection, newUserId);
|
||||
if (result) {
|
||||
this.logger.info(`🔄 Chrome extension user ID updated to: ${newUserId}`);
|
||||
// Note: LiveKit agent is no longer restarted automatically on user ID update
|
||||
// Agents should be managed manually when needed
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Set user context for routing
|
||||
setUserContext(userId: string, sessionId?: string) {
|
||||
this.currentUserId = userId;
|
||||
this.currentSessionId = sessionId;
|
||||
this.logger.info(`🎯 [Chrome Tools] User context set - User: ${userId}, Session: ${sessionId}`);
|
||||
}
|
||||
|
||||
// Handle responses from Chrome extension
|
||||
handleResponse(data: any) {
|
||||
const stats = this.sessionManager.getStats();
|
||||
this.logger.info(`📨 [Chrome Tools] Received response from Chrome extension:`, {
|
||||
messageId: data.id,
|
||||
hasResult: !!data.result,
|
||||
hasError: !!data.error,
|
||||
pendingRequestsCount: stats.pendingRequests,
|
||||
fullData: data,
|
||||
});
|
||||
|
||||
if (data.id) {
|
||||
if (data.error) {
|
||||
this.logger.error(`📨 [Chrome Tools] Chrome extension returned error: ${data.error}`);
|
||||
this.sessionManager.rejectPendingRequest(data.id, new Error(data.error));
|
||||
} else {
|
||||
this.logger.info(
|
||||
`📨 [Chrome Tools] Chrome extension returned success result:`,
|
||||
data.result,
|
||||
);
|
||||
this.sessionManager.resolvePendingRequest(data.id, data.result);
|
||||
}
|
||||
} else {
|
||||
// Filter out ping/heartbeat messages and other non-request messages to reduce noise
|
||||
const isPingMessage =
|
||||
data.type === 'ping' || (data.id && data.id.toString().startsWith('ping_'));
|
||||
const isHeartbeatMessage = !data.id || data.id === undefined;
|
||||
|
||||
if (!isPingMessage && !isHeartbeatMessage) {
|
||||
this.logger.warn(
|
||||
`📨 [Chrome Tools] Received response for unknown or expired request ID: ${data.id}`,
|
||||
);
|
||||
} else {
|
||||
// Log ping/heartbeat messages at debug level to reduce noise
|
||||
this.logger.debug(
|
||||
`📨 [Chrome Tools] Received ping/heartbeat message (ID: ${data.id}, type: ${data.type})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process natural language navigation requests
|
||||
private processNavigationRequest(args: any): any {
|
||||
if (!args || !args.url) {
|
||||
return args;
|
||||
}
|
||||
|
||||
const url = args.url.toLowerCase().trim();
|
||||
|
||||
// Check if it's a natural language request like "google", "open google", etc.
|
||||
const patterns = [/^(?:open\s+|go\s+to\s+|navigate\s+to\s+)?(.+?)(?:\.com)?$/i, /^(.+?)$/i];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = url.match(pattern);
|
||||
if (match) {
|
||||
const site = match[1].toLowerCase().trim();
|
||||
const mappedUrl = this.urlMappings.get(site);
|
||||
if (mappedUrl) {
|
||||
this.logger.info(`Mapped natural language request "${url}" to "${mappedUrl}"`);
|
||||
return { ...args, url: mappedUrl };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no mapping found, check if it's already a valid URL
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
// Try to make it a valid URL
|
||||
const processedUrl = url.includes('.')
|
||||
? `https://${url}`
|
||||
: `https://www.google.com/search?q=${encodeURIComponent(url)}`;
|
||||
this.logger.info(`Processed URL "${url}" to "${processedUrl}"`);
|
||||
return { ...args, url: processedUrl };
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
// Send a general tool call to Chrome extension with routing
|
||||
async callTool(name: string, args: any, sessionId?: string, userId?: string): Promise<any> {
|
||||
// Use current user context if not provided
|
||||
const effectiveUserId = userId || this.currentUserId;
|
||||
const effectiveSessionId = sessionId || this.currentSessionId;
|
||||
|
||||
this.logger.info(`🔧 [Chrome Tools] Calling tool: ${name} with routing context:`, {
|
||||
args,
|
||||
sessionId: effectiveSessionId,
|
||||
userId: effectiveUserId,
|
||||
usingCurrentContext: !userId && !sessionId,
|
||||
});
|
||||
|
||||
const message = {
|
||||
action: 'callTool',
|
||||
params: { name, arguments: args },
|
||||
};
|
||||
|
||||
this.logger.info(`🔧 [Chrome Tools] Sending routed message to extensions:`, message);
|
||||
|
||||
const result = await this.sendToExtensions(message, effectiveSessionId, effectiveUserId);
|
||||
|
||||
this.logger.info(`🔧 [Chrome Tools] Received result from extensions:`, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Get session statistics
|
||||
getSessionStats(): any {
|
||||
return this.sessionManager.getStats();
|
||||
}
|
||||
|
||||
// Get routing statistics
|
||||
getRoutingStats(): any {
|
||||
return this.connectionRouter.getRoutingStats();
|
||||
}
|
||||
|
||||
// Get connection by session ID
|
||||
getConnectionBySessionId(sessionId: string): ExtensionConnection | null {
|
||||
return this.sessionManager.getConnectionBySessionId(sessionId);
|
||||
}
|
||||
|
||||
// Get connection by user ID
|
||||
getConnectionByUserId(userId: string): ExtensionConnection | null {
|
||||
return this.sessionManager.getConnectionByUserId(userId);
|
||||
}
|
||||
|
||||
// Route message to specific connection type
|
||||
async callToolWithConnectionType(
|
||||
name: string,
|
||||
args: any,
|
||||
connectionType: 'newest' | 'oldest' | 'most_active',
|
||||
): Promise<any> {
|
||||
this.logger.info(
|
||||
`🔧 [Chrome Tools] Calling tool: ${name} with connection type: ${connectionType}`,
|
||||
);
|
||||
|
||||
const message = {
|
||||
action: 'callTool',
|
||||
params: { name, arguments: args },
|
||||
};
|
||||
|
||||
const routeResult = this.connectionRouter.routeToConnectionType(message, connectionType);
|
||||
const result = await this.sendToExtensions(message, routeResult.sessionId);
|
||||
|
||||
this.logger.info(`🔧 [Chrome Tools] Tool result from ${connectionType} connection:`, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Check if session can handle message
|
||||
canSessionHandleMessage(sessionId: string, messageType: string): boolean {
|
||||
return this.connectionRouter.canSessionHandleMessage(sessionId, messageType);
|
||||
}
|
||||
|
||||
// Get recommended session for user
|
||||
getRecommendedSessionForUser(userId: string): string | null {
|
||||
return this.connectionRouter.getRecommendedSessionForUser(userId);
|
||||
}
|
||||
|
||||
// Get LiveKit agent for user
|
||||
getLiveKitAgentForUser(userId: string): any {
|
||||
return this.liveKitAgentManager.getAgentForUser(userId);
|
||||
}
|
||||
|
||||
// Get LiveKit agent statistics
|
||||
getLiveKitAgentStats(): any {
|
||||
return this.liveKitAgentManager.getAgentStats();
|
||||
}
|
||||
|
||||
// Get all active LiveKit agents
|
||||
getAllActiveLiveKitAgents(): any[] {
|
||||
return this.liveKitAgentManager.getAllActiveAgents();
|
||||
}
|
||||
|
||||
// Cleanup resources
|
||||
destroy(): void {
|
||||
this.connectionRouter.cleanupRoutingRules();
|
||||
this.liveKitAgentManager.shutdownAllAgents();
|
||||
this.sessionManager.destroy();
|
||||
}
|
||||
|
||||
// Send a message to Chrome extensions with intelligent routing
|
||||
private async sendToExtensions(message: any, sessionId?: string, userId?: string): Promise<any> {
|
||||
const stats = this.sessionManager.getStats();
|
||||
this.logger.info(`📤 [Chrome Tools] Routing message to Chrome extensions:`, {
|
||||
action: message.action,
|
||||
connectionsCount: stats.activeConnections,
|
||||
sessionId,
|
||||
userId,
|
||||
fullMessage: message,
|
||||
});
|
||||
|
||||
if (stats.activeConnections === 0) {
|
||||
this.logger.error('🚫 [Chrome Tools] No Chrome extensions connected');
|
||||
throw new Error('No Chrome extensions connected');
|
||||
}
|
||||
|
||||
// Use connection router to find the best connection
|
||||
let routeResult: RouteResult;
|
||||
try {
|
||||
routeResult = this.connectionRouter.routeMessage(message, sessionId, userId);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to route message:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { connection: extensionConnection, routingReason } = routeResult;
|
||||
const connection = extensionConnection.connection;
|
||||
const readyState = (connection as any).readyState;
|
||||
|
||||
this.logger.info(
|
||||
`📤 [Chrome Tools] Routed to connection - Session: ${extensionConnection.sessionId}, User: ${extensionConnection.userId}, Reason: ${routingReason}, ReadyState: ${readyState}`,
|
||||
);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const messageId = Date.now().toString() + Math.random().toString(36).substring(2, 11);
|
||||
const messageWithId = { ...message, id: messageId };
|
||||
|
||||
// Store the request with session context
|
||||
this.sessionManager.storePendingRequest(
|
||||
messageId,
|
||||
resolve,
|
||||
reject,
|
||||
extensionConnection.sessionId,
|
||||
60000, // 60 second timeout
|
||||
);
|
||||
|
||||
try {
|
||||
// Check if connection is still open before sending
|
||||
if (readyState === 1) {
|
||||
// WebSocket.OPEN
|
||||
this.logger.info(
|
||||
`📤 [Chrome Tools] Sending message with ID ${messageId} to Chrome extension (Session: ${extensionConnection.sessionId}, Routing: ${routingReason}):`,
|
||||
messageWithId,
|
||||
);
|
||||
(connection as any).send(JSON.stringify(messageWithId));
|
||||
} else {
|
||||
this.sessionManager.rejectPendingRequest(
|
||||
messageId,
|
||||
new Error(`Chrome extension connection is not open (readyState: ${readyState})`),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.sessionManager.rejectPendingRequest(messageId, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async navigateToUrl(url: string): Promise<any> {
|
||||
this.logger.info(`Navigating to URL: ${url}`);
|
||||
|
||||
// Process natural language navigation requests
|
||||
const processedArgs = this.processNavigationRequest({ url });
|
||||
|
||||
return await this.sendToExtensions({
|
||||
action: 'navigate',
|
||||
params: processedArgs,
|
||||
});
|
||||
}
|
||||
|
||||
async getPageContent(selector?: string): Promise<any> {
|
||||
this.logger.info(`Getting page content${selector ? ` with selector: ${selector}` : ''}`);
|
||||
|
||||
return await this.sendToExtensions({
|
||||
action: 'getContent',
|
||||
params: { selector },
|
||||
});
|
||||
}
|
||||
|
||||
async clickElement(selector: string): Promise<any> {
|
||||
this.logger.info(`Clicking element: ${selector}`);
|
||||
|
||||
return await this.sendToExtensions({
|
||||
action: 'click',
|
||||
params: { selector },
|
||||
});
|
||||
}
|
||||
|
||||
async fillInput(selector: string, value: string): Promise<any> {
|
||||
this.logger.info(`Filling input ${selector} with value: ${value}`);
|
||||
|
||||
return await this.sendToExtensions({
|
||||
action: 'fillInput',
|
||||
params: { selector, value },
|
||||
});
|
||||
}
|
||||
|
||||
async takeScreenshot(fullPage: boolean = false): Promise<any> {
|
||||
this.logger.info(`Taking screenshot (fullPage: ${fullPage})`);
|
||||
|
||||
return await this.sendToExtensions({
|
||||
action: 'screenshot',
|
||||
params: { fullPage },
|
||||
});
|
||||
}
|
||||
|
||||
async executeScript(script: string): Promise<any> {
|
||||
this.logger.info('Executing script');
|
||||
|
||||
return await this.sendToExtensions({
|
||||
action: 'executeScript',
|
||||
params: { script },
|
||||
});
|
||||
}
|
||||
|
||||
async getCurrentTab(): Promise<any> {
|
||||
this.logger.info('Getting current tab info');
|
||||
|
||||
return await this.sendToExtensions({
|
||||
action: 'getCurrentTab',
|
||||
params: {},
|
||||
});
|
||||
}
|
||||
|
||||
async getAllTabs(): Promise<any> {
|
||||
this.logger.info('Getting all tabs');
|
||||
|
||||
return await this.sendToExtensions({
|
||||
action: 'getAllTabs',
|
||||
params: {},
|
||||
});
|
||||
}
|
||||
|
||||
async switchToTab(tabId: number): Promise<any> {
|
||||
this.logger.info(`Switching to tab: ${tabId}`);
|
||||
|
||||
return await this.sendToExtensions({
|
||||
action: 'switchTab',
|
||||
params: { tabId },
|
||||
});
|
||||
}
|
||||
|
||||
async createNewTab(url?: string): Promise<any> {
|
||||
this.logger.info(`Creating new tab${url ? ` with URL: ${url}` : ''}`);
|
||||
|
||||
return await this.sendToExtensions({
|
||||
action: 'createTab',
|
||||
params: { url },
|
||||
});
|
||||
}
|
||||
|
||||
async closeTab(tabId?: number): Promise<any> {
|
||||
this.logger.info(`Closing tab${tabId ? `: ${tabId}` : ' (current)'}`);
|
||||
|
||||
return await this.sendToExtensions({
|
||||
action: 'closeTab',
|
||||
params: { tabId },
|
||||
});
|
||||
}
|
||||
|
||||
// Browser automation tools matching the native server functionality
|
||||
|
||||
async getWindowsAndTabs(): Promise<any> {
|
||||
this.logger.info('Getting all windows and tabs');
|
||||
return await this.sendToExtensions({
|
||||
action: TOOL_NAMES.BROWSER.GET_WINDOWS_AND_TABS,
|
||||
params: {},
|
||||
});
|
||||
}
|
||||
|
||||
async searchTabsContent(query: string): Promise<any> {
|
||||
this.logger.info(`Searching tabs content for: ${query}`);
|
||||
return await this.sendToExtensions({
|
||||
action: TOOL_NAMES.BROWSER.SEARCH_TABS_CONTENT,
|
||||
params: { query },
|
||||
});
|
||||
}
|
||||
|
||||
async chromeNavigate(args: any): Promise<any> {
|
||||
this.logger.info(`Chrome navigate with args:`, args);
|
||||
|
||||
// Process natural language navigation requests
|
||||
const processedArgs = this.processNavigationRequest(args);
|
||||
|
||||
return await this.sendToExtensions({
|
||||
action: TOOL_NAMES.BROWSER.NAVIGATE,
|
||||
params: processedArgs,
|
||||
});
|
||||
}
|
||||
|
||||
async chromeScreenshot(args: any): Promise<any> {
|
||||
this.logger.info(`Chrome screenshot with args:`, args);
|
||||
return await this.sendToExtensions({
|
||||
action: TOOL_NAMES.BROWSER.SCREENSHOT,
|
||||
params: args,
|
||||
});
|
||||
}
|
||||
|
||||
async chromeCloseTabs(args: any): Promise<any> {
|
||||
this.logger.info(`Chrome close tabs with args:`, args);
|
||||
return await this.sendToExtensions({
|
||||
action: TOOL_NAMES.BROWSER.CLOSE_TABS,
|
||||
params: args,
|
||||
});
|
||||
}
|
||||
|
||||
async chromeGoBackOrForward(args: any): Promise<any> {
|
||||
this.logger.info(`Chrome go back/forward with args:`, args);
|
||||
return await this.sendToExtensions({
|
||||
action: TOOL_NAMES.BROWSER.GO_BACK_OR_FORWARD,
|
||||
params: args,
|
||||
});
|
||||
}
|
||||
|
||||
async chromeGetWebContent(args: any): Promise<any> {
|
||||
this.logger.info(`Chrome get web content with args:`, args);
|
||||
return await this.sendToExtensions({
|
||||
action: TOOL_NAMES.BROWSER.WEB_FETCHER,
|
||||
params: args,
|
||||
});
|
||||
}
|
||||
|
||||
async chromeClickElement(args: any): Promise<any> {
|
||||
this.logger.info(`Chrome click element with args:`, args);
|
||||
return await this.sendToExtensions({
|
||||
action: TOOL_NAMES.BROWSER.CLICK,
|
||||
params: args,
|
||||
});
|
||||
}
|
||||
|
||||
async chromeFillOrSelect(args: any): Promise<any> {
|
||||
this.logger.info(`Chrome fill or select with args:`, args);
|
||||
return await this.sendToExtensions({
|
||||
action: TOOL_NAMES.BROWSER.FILL,
|
||||
params: args,
|
||||
});
|
||||
}
|
||||
|
||||
async chromeGetInteractiveElements(args: any): Promise<any> {
|
||||
this.logger.info(`Chrome get interactive elements with args:`, args);
|
||||
return await this.sendToExtensions({
|
||||
action: TOOL_NAMES.BROWSER.GET_INTERACTIVE_ELEMENTS,
|
||||
params: args,
|
||||
});
|
||||
}
|
||||
|
||||
async chromeNetworkCaptureStart(args: any): Promise<any> {
|
||||
this.logger.info(`Chrome network capture start with args:`, args);
|
||||
return await this.sendToExtensions({
|
||||
action: TOOL_NAMES.BROWSER.NETWORK_CAPTURE_START,
|
||||
params: args,
|
||||
});
|
||||
}
|
||||
|
||||
async chromeNetworkCaptureStop(args: any): Promise<any> {
|
||||
this.logger.info(`Chrome network capture stop with args:`, args);
|
||||
return await this.sendToExtensions({
|
||||
action: TOOL_NAMES.BROWSER.NETWORK_CAPTURE_STOP,
|
||||
params: args,
|
||||
});
|
||||
}
|
||||
|
||||
async chromeNetworkRequest(args: any): Promise<any> {
|
||||
this.logger.info(`Chrome network request with args:`, args);
|
||||
return await this.sendToExtensions({
|
||||
action: TOOL_NAMES.BROWSER.NETWORK_REQUEST,
|
||||
params: args,
|
||||
});
|
||||
}
|
||||
|
||||
async chromeNetworkDebuggerStart(args: any): Promise<any> {
|
||||
this.logger.info(`Chrome network debugger start with args:`, args);
|
||||
return await this.sendToExtensions({
|
||||
action: TOOL_NAMES.BROWSER.NETWORK_DEBUGGER_START,
|
||||
params: args,
|
||||
});
|
||||
}
|
||||
|
||||
async chromeNetworkDebuggerStop(args: any): Promise<any> {
|
||||
this.logger.info(`Chrome network debugger stop with args:`, args);
|
||||
return await this.sendToExtensions({
|
||||
action: TOOL_NAMES.BROWSER.NETWORK_DEBUGGER_STOP,
|
||||
params: args,
|
||||
});
|
||||
}
|
||||
|
||||
async chromeKeyboard(args: any): Promise<any> {
|
||||
this.logger.info(`Chrome keyboard with args:`, args);
|
||||
return await this.sendToExtensions({
|
||||
action: TOOL_NAMES.BROWSER.KEYBOARD,
|
||||
params: args,
|
||||
});
|
||||
}
|
||||
|
||||
async chromeHistory(args: any): Promise<any> {
|
||||
this.logger.info(`Chrome history with args:`, args);
|
||||
return await this.sendToExtensions({
|
||||
action: TOOL_NAMES.BROWSER.HISTORY,
|
||||
params: args,
|
||||
});
|
||||
}
|
||||
|
||||
async chromeBookmarkSearch(args: any): Promise<any> {
|
||||
this.logger.info(`Chrome bookmark search with args:`, args);
|
||||
return await this.sendToExtensions({
|
||||
action: TOOL_NAMES.BROWSER.BOOKMARK_SEARCH,
|
||||
params: args,
|
||||
});
|
||||
}
|
||||
|
||||
async chromeBookmarkAdd(args: any): Promise<any> {
|
||||
this.logger.info(`Chrome bookmark add with args:`, args);
|
||||
return await this.sendToExtensions({
|
||||
action: TOOL_NAMES.BROWSER.BOOKMARK_ADD,
|
||||
params: args,
|
||||
});
|
||||
}
|
||||
|
||||
async chromeBookmarkDelete(args: any): Promise<any> {
|
||||
this.logger.info(`Chrome bookmark delete with args:`, args);
|
||||
return await this.sendToExtensions({
|
||||
action: TOOL_NAMES.BROWSER.BOOKMARK_DELETE,
|
||||
params: args,
|
||||
});
|
||||
}
|
||||
|
||||
async chromeInjectScript(args: any): Promise<any> {
|
||||
this.logger.info(`Chrome inject script with args:`, args);
|
||||
return await this.sendToExtensions({
|
||||
action: TOOL_NAMES.BROWSER.INJECT_SCRIPT,
|
||||
params: args,
|
||||
});
|
||||
}
|
||||
|
||||
async chromeSendCommandToInjectScript(args: any): Promise<any> {
|
||||
this.logger.info(`Chrome send command to inject script with args:`, args);
|
||||
return await this.sendToExtensions({
|
||||
action: TOOL_NAMES.BROWSER.SEND_COMMAND_TO_INJECT_SCRIPT,
|
||||
params: args,
|
||||
});
|
||||
}
|
||||
|
||||
async chromeConsole(args: any): Promise<any> {
|
||||
this.logger.info(`Chrome console with args:`, args);
|
||||
return await this.sendToExtensions({
|
||||
action: TOOL_NAMES.BROWSER.CONSOLE,
|
||||
params: args,
|
||||
});
|
||||
}
|
||||
|
||||
async chromeSearchGoogle(args: any): Promise<any> {
|
||||
this.logger.info(`Chrome search Google with args:`, args);
|
||||
return await this.sendToExtensions({
|
||||
action: TOOL_NAMES.BROWSER.SEARCH_GOOGLE,
|
||||
params: args,
|
||||
});
|
||||
}
|
||||
|
||||
async chromeSubmitForm(args: any): Promise<any> {
|
||||
this.logger.info(`Chrome submit form with args:`, args);
|
||||
return await this.sendToExtensions({
|
||||
action: TOOL_NAMES.BROWSER.SUBMIT_FORM,
|
||||
params: args,
|
||||
});
|
||||
}
|
||||
}
|
287
app/remote-server/src/server/connection-router.ts
Normal file
287
app/remote-server/src/server/connection-router.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
import { Logger } from 'pino';
|
||||
import { SessionManager, ExtensionConnection } from './session-manager.js';
|
||||
|
||||
export interface RoutingRule {
|
||||
sessionId?: string;
|
||||
userId?: string;
|
||||
priority: number;
|
||||
condition?: (connection: ExtensionConnection) => boolean;
|
||||
}
|
||||
|
||||
export interface RouteResult {
|
||||
connection: ExtensionConnection;
|
||||
sessionId: string;
|
||||
userId: string;
|
||||
routingReason: string;
|
||||
}
|
||||
|
||||
export class ConnectionRouter {
|
||||
private logger: Logger;
|
||||
private sessionManager: SessionManager;
|
||||
private routingRules: RoutingRule[] = [];
|
||||
|
||||
constructor(logger: Logger, sessionManager: SessionManager) {
|
||||
this.logger = logger;
|
||||
this.sessionManager = sessionManager;
|
||||
|
||||
// Set up default routing rules
|
||||
this.setupDefaultRoutingRules();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up default routing rules
|
||||
*/
|
||||
private setupDefaultRoutingRules(): void {
|
||||
// Rule 1: Route by exact session ID match (highest priority)
|
||||
this.addRoutingRule({
|
||||
priority: 100,
|
||||
condition: (connection: ExtensionConnection) => true, // Will be filtered by sessionId parameter
|
||||
});
|
||||
|
||||
// Rule 2: Route by user ID (medium priority)
|
||||
this.addRoutingRule({
|
||||
priority: 50,
|
||||
condition: (connection: ExtensionConnection) => connection.isActive,
|
||||
});
|
||||
|
||||
// Rule 3: Route to any active connection (lowest priority)
|
||||
this.addRoutingRule({
|
||||
priority: 10,
|
||||
condition: (connection: ExtensionConnection) => connection.isActive,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a custom routing rule
|
||||
*/
|
||||
addRoutingRule(rule: RoutingRule): void {
|
||||
this.routingRules.push(rule);
|
||||
// Sort by priority (highest first)
|
||||
this.routingRules.sort((a, b) => b.priority - a.priority);
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a message to the appropriate Chrome extension connection
|
||||
*/
|
||||
routeMessage(message: any, sessionId?: string, userId?: string): RouteResult {
|
||||
this.logger.info('Routing message:', {
|
||||
action: message.action,
|
||||
sessionId,
|
||||
userId,
|
||||
messageId: message.id,
|
||||
});
|
||||
|
||||
// Try to route by session ID first
|
||||
if (sessionId) {
|
||||
const connection = this.sessionManager.getConnectionBySessionId(sessionId);
|
||||
if (connection && connection.isActive) {
|
||||
return {
|
||||
connection,
|
||||
sessionId: connection.sessionId,
|
||||
userId: connection.userId,
|
||||
routingReason: 'exact_session_match',
|
||||
};
|
||||
} else {
|
||||
this.logger.warn(`No active connection found for session: ${sessionId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Try to route by user ID
|
||||
if (userId) {
|
||||
const connection = this.sessionManager.getConnectionByUserId(userId);
|
||||
if (connection && connection.isActive) {
|
||||
return {
|
||||
connection,
|
||||
sessionId: connection.sessionId,
|
||||
userId: connection.userId,
|
||||
routingReason: 'user_id_match',
|
||||
};
|
||||
} else {
|
||||
this.logger.warn(`No active connection found for user: ${userId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply routing rules to find best connection
|
||||
const activeConnections = this.sessionManager.getAllActiveConnections();
|
||||
|
||||
if (activeConnections.length === 0) {
|
||||
throw new Error('No active Chrome extension connections available');
|
||||
}
|
||||
|
||||
// Apply routing rules in priority order
|
||||
for (const rule of this.routingRules) {
|
||||
const candidates = activeConnections.filter((conn) => {
|
||||
// Apply session/user filters if specified in rule
|
||||
if (rule.sessionId && conn.sessionId !== rule.sessionId) return false;
|
||||
if (rule.userId && conn.userId !== rule.userId) return false;
|
||||
|
||||
// Apply custom condition
|
||||
if (rule.condition && !rule.condition(conn)) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (candidates.length > 0) {
|
||||
// Use the first candidate (could implement load balancing here)
|
||||
const selectedConnection = candidates[0];
|
||||
|
||||
return {
|
||||
connection: selectedConnection,
|
||||
sessionId: selectedConnection.sessionId,
|
||||
userId: selectedConnection.userId,
|
||||
routingReason: `rule_priority_${rule.priority}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: use first available active connection
|
||||
const fallbackConnection = activeConnections[0];
|
||||
return {
|
||||
connection: fallbackConnection,
|
||||
sessionId: fallbackConnection.sessionId,
|
||||
userId: fallbackConnection.userId,
|
||||
routingReason: 'fallback_first_available',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a message with load balancing
|
||||
*/
|
||||
routeMessageWithLoadBalancing(message: any, sessionId?: string, userId?: string): RouteResult {
|
||||
// For session-specific requests, use exact routing
|
||||
if (sessionId || userId) {
|
||||
return this.routeMessage(message, sessionId, userId);
|
||||
}
|
||||
|
||||
// For general requests, implement round-robin load balancing
|
||||
const activeConnections = this.sessionManager.getAllActiveConnections();
|
||||
|
||||
if (activeConnections.length === 0) {
|
||||
throw new Error('No active Chrome extension connections available');
|
||||
}
|
||||
|
||||
// Simple round-robin based on message timestamp
|
||||
const index = Date.now() % activeConnections.length;
|
||||
const selectedConnection = activeConnections[index];
|
||||
|
||||
return {
|
||||
connection: selectedConnection,
|
||||
sessionId: selectedConnection.sessionId,
|
||||
userId: selectedConnection.userId,
|
||||
routingReason: 'load_balanced_round_robin',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get routing statistics
|
||||
*/
|
||||
getRoutingStats(): any {
|
||||
const stats = this.sessionManager.getStats();
|
||||
return {
|
||||
...stats,
|
||||
routingRules: this.routingRules.length,
|
||||
routingRulesPriorities: this.routingRules.map((rule) => rule.priority),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Route message to specific connection type
|
||||
*/
|
||||
routeToConnectionType(
|
||||
message: any,
|
||||
connectionType: 'newest' | 'oldest' | 'most_active',
|
||||
): RouteResult {
|
||||
const activeConnections = this.sessionManager.getAllActiveConnections();
|
||||
|
||||
if (activeConnections.length === 0) {
|
||||
throw new Error('No active Chrome extension connections available');
|
||||
}
|
||||
|
||||
let selectedConnection: ExtensionConnection;
|
||||
|
||||
switch (connectionType) {
|
||||
case 'newest':
|
||||
selectedConnection = activeConnections.reduce((newest, current) =>
|
||||
current.connectedAt > newest.connectedAt ? current : newest,
|
||||
);
|
||||
break;
|
||||
|
||||
case 'oldest':
|
||||
selectedConnection = activeConnections.reduce((oldest, current) =>
|
||||
current.connectedAt < oldest.connectedAt ? current : oldest,
|
||||
);
|
||||
break;
|
||||
|
||||
case 'most_active':
|
||||
selectedConnection = activeConnections.reduce((mostActive, current) =>
|
||||
current.lastActivity > mostActive.lastActivity ? current : mostActive,
|
||||
);
|
||||
break;
|
||||
|
||||
default:
|
||||
selectedConnection = activeConnections[0];
|
||||
}
|
||||
|
||||
return {
|
||||
connection: selectedConnection,
|
||||
sessionId: selectedConnection.sessionId,
|
||||
userId: selectedConnection.userId,
|
||||
routingReason: `connection_type_${connectionType}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific session can handle a message type
|
||||
*/
|
||||
canSessionHandleMessage(sessionId: string, messageType: string): boolean {
|
||||
const connection = this.sessionManager.getConnectionBySessionId(sessionId);
|
||||
|
||||
if (!connection || !connection.isActive) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if connection has been active recently
|
||||
const timeSinceActivity = Date.now() - connection.lastActivity;
|
||||
const maxInactiveTime = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
if (timeSinceActivity > maxInactiveTime) {
|
||||
this.logger.warn(`Session ${sessionId} has been inactive for ${timeSinceActivity}ms`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Add message type specific checks here if needed
|
||||
// For now, assume all active connections can handle all message types
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recommended session for a user
|
||||
*/
|
||||
getRecommendedSessionForUser(userId: string): string | null {
|
||||
const connection = this.sessionManager.getConnectionByUserId(userId);
|
||||
return connection ? connection.sessionId : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup inactive routing rules
|
||||
*/
|
||||
cleanupRoutingRules(): void {
|
||||
// Remove rules that reference non-existent sessions
|
||||
const validSessionIds = new Set(
|
||||
this.sessionManager.getAllActiveConnections().map((conn) => conn.sessionId),
|
||||
);
|
||||
|
||||
const initialRuleCount = this.routingRules.length;
|
||||
this.routingRules = this.routingRules.filter((rule) => {
|
||||
if (rule.sessionId && !validSessionIds.has(rule.sessionId)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const removedRules = initialRuleCount - this.routingRules.length;
|
||||
if (removedRules > 0) {
|
||||
this.logger.info(`Cleaned up ${removedRules} invalid routing rules`);
|
||||
}
|
||||
}
|
||||
}
|
317
app/remote-server/src/server/livekit-agent-manager.ts
Normal file
317
app/remote-server/src/server/livekit-agent-manager.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
import { Logger } from 'pino';
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import { SessionManager, ExtensionConnection } from './session-manager.js';
|
||||
import path from 'path';
|
||||
|
||||
export interface LiveKitAgentInstance {
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
process: ChildProcess;
|
||||
roomName: string;
|
||||
startedAt: number;
|
||||
status: 'starting' | 'running' | 'stopping' | 'stopped' | 'error';
|
||||
pid?: number;
|
||||
}
|
||||
|
||||
export class LiveKitAgentManager {
|
||||
private logger: Logger;
|
||||
private sessionManager: SessionManager;
|
||||
private agentInstances: Map<string, LiveKitAgentInstance> = new Map(); // sessionId -> agent
|
||||
private userToAgent: Map<string, string> = new Map(); // userId -> sessionId
|
||||
private agentPath: string;
|
||||
private liveKitConfig: any;
|
||||
|
||||
constructor(logger: Logger, sessionManager: SessionManager, agentPath?: string) {
|
||||
this.logger = logger;
|
||||
this.sessionManager = sessionManager;
|
||||
this.agentPath = agentPath || path.join(process.cwd(), '../../agent-livekit');
|
||||
this.liveKitConfig = this.loadLiveKitConfig();
|
||||
}
|
||||
|
||||
private loadLiveKitConfig(): any {
|
||||
// Default LiveKit configuration
|
||||
return {
|
||||
livekit_url: process.env.LIVEKIT_URL || 'ws://localhost:7880',
|
||||
api_key: process.env.LIVEKIT_API_KEY || 'devkey',
|
||||
api_secret: process.env.LIVEKIT_API_SECRET || 'secret',
|
||||
room_prefix: 'mcp-chrome-user-',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a LiveKit agent for a Chrome extension connection
|
||||
*/
|
||||
async startAgentForConnection(connection: ExtensionConnection): Promise<LiveKitAgentInstance> {
|
||||
const { userId, sessionId } = connection;
|
||||
|
||||
// Check if agent already exists for this user
|
||||
const existingSessionId = this.userToAgent.get(userId);
|
||||
if (existingSessionId && this.agentInstances.has(existingSessionId)) {
|
||||
const existingAgent = this.agentInstances.get(existingSessionId)!;
|
||||
if (existingAgent.status === 'running' || existingAgent.status === 'starting') {
|
||||
this.logger.info(`Agent already running for user ${userId}, reusing existing agent`);
|
||||
return existingAgent;
|
||||
}
|
||||
}
|
||||
|
||||
// Create room name based on user ID
|
||||
const roomName = `${this.liveKitConfig.room_prefix}${userId}`;
|
||||
|
||||
this.logger.info(
|
||||
`Starting LiveKit agent for user ${userId}, session ${sessionId}, room ${roomName}`,
|
||||
);
|
||||
|
||||
// Create agent instance record
|
||||
const agentInstance: LiveKitAgentInstance = {
|
||||
userId,
|
||||
sessionId,
|
||||
process: null as any, // Will be set below
|
||||
roomName,
|
||||
startedAt: Date.now(),
|
||||
status: 'starting',
|
||||
};
|
||||
|
||||
try {
|
||||
// Spawn the full LiveKit agent process directly
|
||||
const agentProcess = spawn(
|
||||
'python',
|
||||
[
|
||||
'livekit_agent.py',
|
||||
'start',
|
||||
'--url',
|
||||
this.liveKitConfig.livekit_url,
|
||||
'--api-key',
|
||||
this.liveKitConfig.api_key,
|
||||
'--api-secret',
|
||||
this.liveKitConfig.api_secret,
|
||||
],
|
||||
{
|
||||
cwd: this.agentPath,
|
||||
env: {
|
||||
...process.env,
|
||||
LIVEKIT_URL: this.liveKitConfig.livekit_url,
|
||||
LIVEKIT_API_KEY: this.liveKitConfig.api_key,
|
||||
LIVEKIT_API_SECRET: this.liveKitConfig.api_secret,
|
||||
MCP_SERVER_URL: 'http://localhost:3001/mcp',
|
||||
CHROME_USER_ID: userId, // Pass the user ID as environment variable
|
||||
// Voice processing optimization
|
||||
LIVEKIT_ROOM_NAME: roomName,
|
||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY || '',
|
||||
DEEPGRAM_API_KEY: process.env.DEEPGRAM_API_KEY || '',
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
},
|
||||
);
|
||||
|
||||
agentInstance.process = agentProcess;
|
||||
agentInstance.pid = agentProcess.pid;
|
||||
|
||||
// Set up process event handlers
|
||||
agentProcess.stdout?.on('data', (data) => {
|
||||
const output = data.toString();
|
||||
this.logger.info(`[Agent ${userId}] ${output.trim()}`);
|
||||
|
||||
// Check for successful startup
|
||||
if (
|
||||
output.includes('Agent initialized successfully') ||
|
||||
output.includes('LiveKit agent started')
|
||||
) {
|
||||
agentInstance.status = 'running';
|
||||
this.logger.info(`LiveKit agent for user ${userId} is now running`);
|
||||
}
|
||||
});
|
||||
|
||||
agentProcess.stderr?.on('data', (data) => {
|
||||
const error = data.toString();
|
||||
this.logger.error(`[Agent ${userId}] ERROR: ${error.trim()}`);
|
||||
});
|
||||
|
||||
agentProcess.on('close', (code) => {
|
||||
this.logger.info(`LiveKit agent for user ${userId} exited with code ${code}`);
|
||||
agentInstance.status = code === 0 ? 'stopped' : 'error';
|
||||
|
||||
// Clean up mappings
|
||||
this.agentInstances.delete(sessionId);
|
||||
this.userToAgent.delete(userId);
|
||||
});
|
||||
|
||||
agentProcess.on('error', (error) => {
|
||||
this.logger.error(`Failed to start LiveKit agent for user ${userId}:`, error);
|
||||
agentInstance.status = 'error';
|
||||
});
|
||||
|
||||
// Store the agent instance
|
||||
this.agentInstances.set(sessionId, agentInstance);
|
||||
this.userToAgent.set(userId, sessionId);
|
||||
|
||||
this.logger.info(
|
||||
`LiveKit agent process started for user ${userId} with PID ${agentProcess.pid}`,
|
||||
);
|
||||
|
||||
return agentInstance;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error starting LiveKit agent for user ${userId}:`, error);
|
||||
agentInstance.status = 'error';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a LiveKit agent for a user
|
||||
*/
|
||||
async stopAgentForUser(userId: string): Promise<boolean> {
|
||||
const sessionId = this.userToAgent.get(userId);
|
||||
if (!sessionId) {
|
||||
this.logger.warn(`No agent found for user ${userId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.stopAgentForSession(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a LiveKit agent for a session
|
||||
*/
|
||||
async stopAgentForSession(sessionId: string): Promise<boolean> {
|
||||
const agentInstance = this.agentInstances.get(sessionId);
|
||||
if (!agentInstance) {
|
||||
this.logger.warn(`No agent found for session ${sessionId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
this.logger.info(
|
||||
`Stopping LiveKit agent for user ${agentInstance.userId}, session ${sessionId}`,
|
||||
);
|
||||
|
||||
agentInstance.status = 'stopping';
|
||||
|
||||
try {
|
||||
if (agentInstance.process && !agentInstance.process.killed) {
|
||||
// Try graceful shutdown first
|
||||
agentInstance.process.kill('SIGTERM');
|
||||
|
||||
// Force kill after 5 seconds if still running
|
||||
setTimeout(() => {
|
||||
if (agentInstance.process && !agentInstance.process.killed) {
|
||||
this.logger.warn(`Force killing LiveKit agent for user ${agentInstance.userId}`);
|
||||
agentInstance.process.kill('SIGKILL');
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error stopping LiveKit agent for user ${agentInstance.userId}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Chrome extension connection
|
||||
*/
|
||||
async onChromeExtensionConnected(connection: ExtensionConnection): Promise<void> {
|
||||
this.logger.info(
|
||||
`Chrome extension connected, starting LiveKit agent for user ${connection.userId}`,
|
||||
);
|
||||
|
||||
try {
|
||||
await this.startAgentForConnection(connection);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to start LiveKit agent for Chrome connection:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Chrome extension disconnection
|
||||
*/
|
||||
async onChromeExtensionDisconnected(connection: ExtensionConnection): Promise<void> {
|
||||
this.logger.info(
|
||||
`Chrome extension disconnected, stopping LiveKit agent for user ${connection.userId}`,
|
||||
);
|
||||
|
||||
try {
|
||||
await this.stopAgentForUser(connection.userId);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to stop LiveKit agent for Chrome disconnection:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agent instance for a user
|
||||
*/
|
||||
getAgentForUser(userId: string): LiveKitAgentInstance | null {
|
||||
const sessionId = this.userToAgent.get(userId);
|
||||
return sessionId ? this.agentInstances.get(sessionId) || null : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agent instance for a session
|
||||
*/
|
||||
getAgentForSession(sessionId: string): LiveKitAgentInstance | null {
|
||||
return this.agentInstances.get(sessionId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active agents
|
||||
*/
|
||||
getAllActiveAgents(): LiveKitAgentInstance[] {
|
||||
return Array.from(this.agentInstances.values()).filter(
|
||||
(agent) => agent.status === 'running' || agent.status === 'starting',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agent statistics
|
||||
*/
|
||||
getAgentStats(): any {
|
||||
const agents = Array.from(this.agentInstances.values());
|
||||
return {
|
||||
totalAgents: agents.length,
|
||||
runningAgents: agents.filter((a) => a.status === 'running').length,
|
||||
startingAgents: agents.filter((a) => a.status === 'starting').length,
|
||||
stoppedAgents: agents.filter((a) => a.status === 'stopped').length,
|
||||
errorAgents: agents.filter((a) => a.status === 'error').length,
|
||||
agentsByUser: Object.fromEntries(this.userToAgent.entries()),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup stopped agents
|
||||
*/
|
||||
cleanupStoppedAgents(): void {
|
||||
const stoppedAgents: string[] = [];
|
||||
|
||||
for (const [sessionId, agent] of this.agentInstances.entries()) {
|
||||
if (agent.status === 'stopped' || agent.status === 'error') {
|
||||
stoppedAgents.push(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const sessionId of stoppedAgents) {
|
||||
const agent = this.agentInstances.get(sessionId);
|
||||
if (agent) {
|
||||
this.agentInstances.delete(sessionId);
|
||||
this.userToAgent.delete(agent.userId);
|
||||
this.logger.info(`Cleaned up stopped agent for user ${agent.userId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown all agents
|
||||
*/
|
||||
async shutdownAllAgents(): Promise<void> {
|
||||
this.logger.info('Shutting down all LiveKit agents...');
|
||||
|
||||
const shutdownPromises = Array.from(this.agentInstances.keys()).map((sessionId) =>
|
||||
this.stopAgentForSession(sessionId),
|
||||
);
|
||||
|
||||
await Promise.all(shutdownPromises);
|
||||
|
||||
this.agentInstances.clear();
|
||||
this.userToAgent.clear();
|
||||
|
||||
this.logger.info('All LiveKit agents shut down');
|
||||
}
|
||||
}
|
256
app/remote-server/src/server/mcp-remote-server.ts
Normal file
256
app/remote-server/src/server/mcp-remote-server.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { Logger } from 'pino';
|
||||
import { ChromeTools } from './chrome-tools.js';
|
||||
import { TOOL_SCHEMAS, TOOL_NAMES } from 'chrome-mcp-shared';
|
||||
|
||||
export class MCPRemoteServer {
|
||||
private server: Server;
|
||||
private chromeTools: ChromeTools;
|
||||
private logger: Logger;
|
||||
|
||||
constructor(logger: Logger) {
|
||||
this.logger = logger;
|
||||
this.server = new Server(
|
||||
{
|
||||
name: 'mcp-chrome-remote-server',
|
||||
version: '1.0.0',
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
this.chromeTools = new ChromeTools(logger);
|
||||
this.setupHandlers();
|
||||
}
|
||||
|
||||
// Register Chrome extension connection with session management
|
||||
registerChromeExtension(
|
||||
connection: any,
|
||||
userId?: string,
|
||||
metadata?: any,
|
||||
): { userId: string; sessionId: string; connectionId: string } {
|
||||
return this.chromeTools.registerExtension(connection, userId, metadata);
|
||||
}
|
||||
|
||||
// Unregister Chrome extension connection
|
||||
unregisterChromeExtension(connection: any): boolean {
|
||||
return this.chromeTools.unregisterExtension(connection);
|
||||
}
|
||||
|
||||
// Get session statistics
|
||||
getSessionStats(): any {
|
||||
return this.chromeTools.getSessionStats();
|
||||
}
|
||||
|
||||
// Handle responses from Chrome extension
|
||||
handleChromeResponse(data: any) {
|
||||
this.chromeTools.handleResponse(data);
|
||||
}
|
||||
|
||||
// Update Chrome extension user ID
|
||||
updateChromeExtensionUserId(connection: any, newUserId: string): any {
|
||||
return this.chromeTools.updateExtensionUserId(connection, newUserId);
|
||||
}
|
||||
|
||||
// Set user context for routing
|
||||
setUserContext(userId: string, sessionId?: string) {
|
||||
this.chromeTools.setUserContext(userId, sessionId);
|
||||
}
|
||||
|
||||
// Connect a streaming transport to the MCP server
|
||||
async connectTransport(transport: SSEServerTransport | StreamableHTTPServerTransport) {
|
||||
try {
|
||||
await this.server.connect(transport);
|
||||
this.logger.info('MCP server connected to streaming transport');
|
||||
} catch (error) {
|
||||
this.logger.error('Error connecting MCP server to transport:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private setupHandlers() {
|
||||
// List available tools
|
||||
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
return { tools: TOOL_SCHEMAS };
|
||||
});
|
||||
|
||||
// Handle tool calls
|
||||
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { name, arguments: args } = request.params;
|
||||
|
||||
this.logger.info('🔧 [MCP Server] Handling tool call:', {
|
||||
toolName: name,
|
||||
hasArgs: !!args,
|
||||
args,
|
||||
});
|
||||
|
||||
try {
|
||||
let result;
|
||||
|
||||
switch (name) {
|
||||
// Legacy tool names for backward compatibility
|
||||
case 'navigate_to_url':
|
||||
result = await this.chromeTools.navigateToUrl((args as any)?.url);
|
||||
break;
|
||||
case 'get_page_content':
|
||||
result = await this.chromeTools.getPageContent((args as any)?.selector);
|
||||
break;
|
||||
case 'click_element':
|
||||
result = await this.chromeTools.clickElement((args as any)?.selector);
|
||||
break;
|
||||
case 'fill_input':
|
||||
result = await this.chromeTools.fillInput(
|
||||
(args as any)?.selector,
|
||||
(args as any)?.value,
|
||||
);
|
||||
break;
|
||||
case 'take_screenshot':
|
||||
result = await this.chromeTools.takeScreenshot((args as any)?.fullPage);
|
||||
break;
|
||||
|
||||
// Browser automation tools matching native server
|
||||
case TOOL_NAMES.BROWSER.GET_WINDOWS_AND_TABS:
|
||||
result = await this.chromeTools.getWindowsAndTabs();
|
||||
break;
|
||||
case TOOL_NAMES.BROWSER.SEARCH_TABS_CONTENT:
|
||||
result = await this.chromeTools.searchTabsContent((args as any)?.query);
|
||||
break;
|
||||
case TOOL_NAMES.BROWSER.NAVIGATE:
|
||||
result = await this.chromeTools.chromeNavigate(args);
|
||||
break;
|
||||
case TOOL_NAMES.BROWSER.SCREENSHOT:
|
||||
result = await this.chromeTools.chromeScreenshot(args);
|
||||
break;
|
||||
case TOOL_NAMES.BROWSER.CLOSE_TABS:
|
||||
result = await this.chromeTools.chromeCloseTabs(args);
|
||||
break;
|
||||
case TOOL_NAMES.BROWSER.GO_BACK_OR_FORWARD:
|
||||
result = await this.chromeTools.chromeGoBackOrForward(args);
|
||||
break;
|
||||
case TOOL_NAMES.BROWSER.WEB_FETCHER:
|
||||
result = await this.chromeTools.chromeGetWebContent(args);
|
||||
break;
|
||||
case TOOL_NAMES.BROWSER.CLICK:
|
||||
result = await this.chromeTools.chromeClickElement(args);
|
||||
break;
|
||||
case TOOL_NAMES.BROWSER.FILL:
|
||||
result = await this.chromeTools.chromeFillOrSelect(args);
|
||||
break;
|
||||
case TOOL_NAMES.BROWSER.GET_INTERACTIVE_ELEMENTS:
|
||||
result = await this.chromeTools.chromeGetInteractiveElements(args);
|
||||
break;
|
||||
case TOOL_NAMES.BROWSER.NETWORK_CAPTURE_START:
|
||||
result = await this.chromeTools.chromeNetworkCaptureStart(args);
|
||||
break;
|
||||
case TOOL_NAMES.BROWSER.NETWORK_CAPTURE_STOP:
|
||||
result = await this.chromeTools.chromeNetworkCaptureStop(args);
|
||||
break;
|
||||
case TOOL_NAMES.BROWSER.NETWORK_REQUEST:
|
||||
result = await this.chromeTools.chromeNetworkRequest(args);
|
||||
break;
|
||||
case TOOL_NAMES.BROWSER.NETWORK_DEBUGGER_START:
|
||||
result = await this.chromeTools.chromeNetworkDebuggerStart(args);
|
||||
break;
|
||||
case TOOL_NAMES.BROWSER.NETWORK_DEBUGGER_STOP:
|
||||
result = await this.chromeTools.chromeNetworkDebuggerStop(args);
|
||||
break;
|
||||
case TOOL_NAMES.BROWSER.KEYBOARD:
|
||||
result = await this.chromeTools.chromeKeyboard(args);
|
||||
break;
|
||||
case TOOL_NAMES.BROWSER.HISTORY:
|
||||
result = await this.chromeTools.chromeHistory(args);
|
||||
break;
|
||||
case TOOL_NAMES.BROWSER.BOOKMARK_SEARCH:
|
||||
result = await this.chromeTools.chromeBookmarkSearch(args);
|
||||
break;
|
||||
case TOOL_NAMES.BROWSER.BOOKMARK_ADD:
|
||||
result = await this.chromeTools.chromeBookmarkAdd(args);
|
||||
break;
|
||||
case TOOL_NAMES.BROWSER.BOOKMARK_DELETE:
|
||||
result = await this.chromeTools.chromeBookmarkDelete(args);
|
||||
break;
|
||||
case TOOL_NAMES.BROWSER.INJECT_SCRIPT:
|
||||
result = await this.chromeTools.chromeInjectScript(args);
|
||||
break;
|
||||
case TOOL_NAMES.BROWSER.SEND_COMMAND_TO_INJECT_SCRIPT:
|
||||
result = await this.chromeTools.chromeSendCommandToInjectScript(args);
|
||||
break;
|
||||
case TOOL_NAMES.BROWSER.CONSOLE:
|
||||
result = await this.chromeTools.chromeConsole(args);
|
||||
break;
|
||||
case TOOL_NAMES.BROWSER.SEARCH_GOOGLE:
|
||||
result = await this.chromeTools.chromeSearchGoogle(args);
|
||||
break;
|
||||
case TOOL_NAMES.BROWSER.SUBMIT_FORM:
|
||||
result = await this.chromeTools.chromeSubmitForm(args);
|
||||
break;
|
||||
default:
|
||||
// Use the general tool call method for any tools not explicitly mapped
|
||||
result = await this.chromeTools.callTool(name, args);
|
||||
}
|
||||
|
||||
this.logger.info('🔧 [MCP Server] Tool call completed:', {
|
||||
toolName: name,
|
||||
hasResult: !!result,
|
||||
result,
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(result, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`🔧 [MCP Server] Error executing tool ${name}:`, error);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async handleMessage(message: any): Promise<any> {
|
||||
// This method will handle incoming WebSocket messages
|
||||
// and route them to the appropriate MCP server handlers
|
||||
try {
|
||||
// For now, we'll implement a simple message routing
|
||||
// In a full implementation, you'd want to properly handle the MCP protocol
|
||||
|
||||
if (message.method === 'tools/list') {
|
||||
const response = await this.server.request(
|
||||
{ method: 'tools/list', params: {} },
|
||||
ListToolsRequestSchema,
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
if (message.method === 'tools/call') {
|
||||
const response = await this.server.request(
|
||||
{ method: 'tools/call', params: message.params },
|
||||
CallToolRequestSchema,
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
return { error: 'Unknown method' };
|
||||
} catch (error) {
|
||||
this.logger.error('Error handling message:', error);
|
||||
return { error: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
}
|
||||
}
|
476
app/remote-server/src/server/session-manager.ts
Normal file
476
app/remote-server/src/server/session-manager.ts
Normal file
@@ -0,0 +1,476 @@
|
||||
import { Logger } from 'pino';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
export interface UserSession {
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
connectionId: string;
|
||||
createdAt: number;
|
||||
lastActivity: number;
|
||||
metadata: {
|
||||
userAgent?: string;
|
||||
ipAddress?: string;
|
||||
extensionVersion?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ExtensionConnection {
|
||||
connection: any;
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
connectionId: string;
|
||||
connectedAt: number;
|
||||
lastActivity: number;
|
||||
isActive: boolean;
|
||||
metadata: any;
|
||||
}
|
||||
|
||||
export interface PendingRequest {
|
||||
resolve: Function;
|
||||
reject: Function;
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
createdAt: number;
|
||||
timeout: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
export class SessionManager {
|
||||
private logger: Logger;
|
||||
private userSessions: Map<string, UserSession> = new Map();
|
||||
private extensionConnections: Map<string, ExtensionConnection> = new Map();
|
||||
private sessionToConnection: Map<string, string> = new Map();
|
||||
private userToSessions: Map<string, Set<string>> = new Map();
|
||||
private pendingRequests: Map<string, PendingRequest> = new Map();
|
||||
private cleanupInterval: NodeJS.Timeout;
|
||||
|
||||
constructor(logger: Logger) {
|
||||
this.logger = logger;
|
||||
|
||||
// Start cleanup interval for stale sessions and connections
|
||||
this.cleanupInterval = setInterval(() => {
|
||||
this.cleanupStaleConnections();
|
||||
this.cleanupExpiredRequests();
|
||||
}, 30000); // Check every 30 seconds
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique user ID
|
||||
*/
|
||||
generateUserId(): string {
|
||||
return `user_${randomUUID()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique session ID
|
||||
*/
|
||||
generateSessionId(): string {
|
||||
return `session_${randomUUID()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique connection ID
|
||||
*/
|
||||
generateConnectionId(): string {
|
||||
return `conn_${randomUUID()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new Chrome extension connection
|
||||
*/
|
||||
registerExtensionConnection(
|
||||
connection: any,
|
||||
userId?: string,
|
||||
metadata: any = {},
|
||||
): { userId: string; sessionId: string; connectionId: string } {
|
||||
const actualUserId = userId || this.generateUserId();
|
||||
const sessionId = this.generateSessionId();
|
||||
const connectionId = this.generateConnectionId();
|
||||
|
||||
// Create user session
|
||||
const userSession: UserSession = {
|
||||
userId: actualUserId,
|
||||
sessionId,
|
||||
connectionId,
|
||||
createdAt: Date.now(),
|
||||
lastActivity: Date.now(),
|
||||
metadata: {
|
||||
userAgent: metadata.userAgent,
|
||||
ipAddress: metadata.ipAddress,
|
||||
extensionVersion: metadata.extensionVersion,
|
||||
...metadata,
|
||||
},
|
||||
};
|
||||
|
||||
// Create extension connection
|
||||
const extensionConnection: ExtensionConnection = {
|
||||
connection,
|
||||
userId: actualUserId,
|
||||
sessionId,
|
||||
connectionId,
|
||||
connectedAt: Date.now(),
|
||||
lastActivity: Date.now(),
|
||||
isActive: true,
|
||||
metadata,
|
||||
};
|
||||
|
||||
// Store mappings
|
||||
this.userSessions.set(sessionId, userSession);
|
||||
this.extensionConnections.set(connectionId, extensionConnection);
|
||||
this.sessionToConnection.set(sessionId, connectionId);
|
||||
|
||||
// Track user sessions
|
||||
if (!this.userToSessions.has(actualUserId)) {
|
||||
this.userToSessions.set(actualUserId, new Set());
|
||||
}
|
||||
this.userToSessions.get(actualUserId)!.add(sessionId);
|
||||
|
||||
this.logger.info(
|
||||
`Extension registered - User: ${actualUserId}, Session: ${sessionId}, Connection: ${connectionId}`,
|
||||
);
|
||||
this.logConnectionStats();
|
||||
|
||||
return { userId: actualUserId, sessionId, connectionId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a Chrome extension connection
|
||||
*/
|
||||
unregisterExtensionConnection(connection: any): boolean {
|
||||
// Find connection by reference
|
||||
let connectionToRemove: ExtensionConnection | null = null;
|
||||
let connectionId: string | null = null;
|
||||
|
||||
for (const [id, extConnection] of this.extensionConnections.entries()) {
|
||||
if (extConnection.connection === connection) {
|
||||
connectionToRemove = extConnection;
|
||||
connectionId = id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!connectionToRemove || !connectionId) {
|
||||
this.logger.warn('Attempted to unregister unknown connection');
|
||||
return false;
|
||||
}
|
||||
|
||||
const { userId, sessionId } = connectionToRemove;
|
||||
|
||||
// Remove from all mappings
|
||||
this.extensionConnections.delete(connectionId);
|
||||
this.sessionToConnection.delete(sessionId);
|
||||
this.userSessions.delete(sessionId);
|
||||
|
||||
// Update user sessions
|
||||
const userSessions = this.userToSessions.get(userId);
|
||||
if (userSessions) {
|
||||
userSessions.delete(sessionId);
|
||||
if (userSessions.size === 0) {
|
||||
this.userToSessions.delete(userId);
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel any pending requests for this session
|
||||
this.cancelPendingRequestsForSession(sessionId);
|
||||
|
||||
this.logger.info(
|
||||
`Extension unregistered - User: ${userId}, Session: ${sessionId}, Connection: ${connectionId}`,
|
||||
);
|
||||
this.logConnectionStats();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get extension connection by session ID
|
||||
*/
|
||||
getConnectionBySessionId(sessionId: string): ExtensionConnection | null {
|
||||
const connectionId = this.sessionToConnection.get(sessionId);
|
||||
if (!connectionId) {
|
||||
return null;
|
||||
}
|
||||
return this.extensionConnections.get(connectionId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get extension connection by user ID (returns first active connection)
|
||||
*/
|
||||
getConnectionByUserId(userId: string): ExtensionConnection | null {
|
||||
const userSessions = this.userToSessions.get(userId);
|
||||
if (!userSessions || userSessions.size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find first active connection
|
||||
for (const sessionId of userSessions) {
|
||||
const connection = this.getConnectionBySessionId(sessionId);
|
||||
if (connection && connection.isActive) {
|
||||
return connection;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active connections
|
||||
*/
|
||||
getAllActiveConnections(): ExtensionConnection[] {
|
||||
return Array.from(this.extensionConnections.values()).filter((conn) => conn.isActive);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update last activity for a session
|
||||
*/
|
||||
updateSessionActivity(sessionId: string): void {
|
||||
const session = this.userSessions.get(sessionId);
|
||||
if (session) {
|
||||
session.lastActivity = Date.now();
|
||||
}
|
||||
|
||||
const connectionId = this.sessionToConnection.get(sessionId);
|
||||
if (connectionId) {
|
||||
const connection = this.extensionConnections.get(connectionId);
|
||||
if (connection) {
|
||||
connection.lastActivity = Date.now();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user ID for an existing extension connection
|
||||
*/
|
||||
updateExtensionUserId(connection: any, newUserId: string): any {
|
||||
// Find the extension connection
|
||||
let targetConnection: ExtensionConnection | null = null;
|
||||
let targetConnectionId: string | null = null;
|
||||
|
||||
for (const [connectionId, extConnection] of this.extensionConnections.entries()) {
|
||||
if (extConnection.connection === connection) {
|
||||
targetConnection = extConnection;
|
||||
targetConnectionId = connectionId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetConnection || !targetConnectionId) {
|
||||
this.logger.warn('Extension connection not found for user ID update');
|
||||
return null;
|
||||
}
|
||||
|
||||
const oldUserId = targetConnection.userId;
|
||||
const sessionId = targetConnection.sessionId;
|
||||
|
||||
// Update the extension connection
|
||||
targetConnection.userId = newUserId;
|
||||
targetConnection.lastActivity = Date.now();
|
||||
|
||||
// Update the user session
|
||||
const userSession = this.userSessions.get(sessionId);
|
||||
if (userSession) {
|
||||
userSession.userId = newUserId;
|
||||
userSession.lastActivity = Date.now();
|
||||
}
|
||||
|
||||
// Update user to sessions mapping
|
||||
const oldUserSessions = this.userToSessions.get(oldUserId);
|
||||
if (oldUserSessions) {
|
||||
oldUserSessions.delete(sessionId);
|
||||
if (oldUserSessions.size === 0) {
|
||||
this.userToSessions.delete(oldUserId);
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.userToSessions.has(newUserId)) {
|
||||
this.userToSessions.set(newUserId, new Set());
|
||||
}
|
||||
this.userToSessions.get(newUserId)!.add(sessionId);
|
||||
|
||||
this.logger.info(`Updated extension user ID from ${oldUserId} to ${newUserId}`);
|
||||
|
||||
return {
|
||||
userId: newUserId,
|
||||
oldUserId: oldUserId,
|
||||
sessionId: sessionId,
|
||||
connectionId: targetConnectionId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a pending request with session context
|
||||
*/
|
||||
storePendingRequest(
|
||||
requestId: string,
|
||||
resolve: Function,
|
||||
reject: Function,
|
||||
sessionId: string,
|
||||
timeoutMs: number = 60000,
|
||||
): void {
|
||||
const session = this.userSessions.get(sessionId);
|
||||
if (!session) {
|
||||
reject(new Error(`Session ${sessionId} not found`));
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
this.pendingRequests.delete(requestId);
|
||||
reject(new Error(`Request ${requestId} timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
const pendingRequest: PendingRequest = {
|
||||
resolve,
|
||||
reject,
|
||||
userId: session.userId,
|
||||
sessionId,
|
||||
createdAt: Date.now(),
|
||||
timeout,
|
||||
};
|
||||
|
||||
this.pendingRequests.set(requestId, pendingRequest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a pending request
|
||||
*/
|
||||
resolvePendingRequest(requestId: string, result: any): boolean {
|
||||
const request = this.pendingRequests.get(requestId);
|
||||
if (!request) {
|
||||
return false;
|
||||
}
|
||||
|
||||
clearTimeout(request.timeout);
|
||||
this.pendingRequests.delete(requestId);
|
||||
request.resolve(result);
|
||||
|
||||
// Update session activity
|
||||
this.updateSessionActivity(request.sessionId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a pending request
|
||||
*/
|
||||
rejectPendingRequest(requestId: string, error: any): boolean {
|
||||
const request = this.pendingRequests.get(requestId);
|
||||
if (!request) {
|
||||
return false;
|
||||
}
|
||||
|
||||
clearTimeout(request.timeout);
|
||||
this.pendingRequests.delete(requestId);
|
||||
request.reject(error);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel all pending requests for a session
|
||||
*/
|
||||
private cancelPendingRequestsForSession(sessionId: string): void {
|
||||
const requestsToCancel: string[] = [];
|
||||
|
||||
for (const [requestId, request] of this.pendingRequests.entries()) {
|
||||
if (request.sessionId === sessionId) {
|
||||
requestsToCancel.push(requestId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const requestId of requestsToCancel) {
|
||||
this.rejectPendingRequest(requestId, new Error(`Session ${sessionId} disconnected`));
|
||||
}
|
||||
|
||||
this.logger.info(
|
||||
`Cancelled ${requestsToCancel.length} pending requests for session ${sessionId}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up stale connections and sessions
|
||||
*/
|
||||
private cleanupStaleConnections(): void {
|
||||
const now = Date.now();
|
||||
const staleThreshold = 5 * 60 * 1000; // 5 minutes
|
||||
const connectionsToRemove: string[] = [];
|
||||
|
||||
for (const [connectionId, connection] of this.extensionConnections.entries()) {
|
||||
if (now - connection.lastActivity > staleThreshold) {
|
||||
connectionsToRemove.push(connectionId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const connectionId of connectionsToRemove) {
|
||||
const connection = this.extensionConnections.get(connectionId);
|
||||
if (connection) {
|
||||
this.logger.info(`Cleaning up stale connection: ${connectionId}`);
|
||||
this.unregisterExtensionConnection(connection.connection);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired requests
|
||||
*/
|
||||
private cleanupExpiredRequests(): void {
|
||||
const now = Date.now();
|
||||
const expiredThreshold = 2 * 60 * 1000; // 2 minutes
|
||||
const requestsToRemove: string[] = [];
|
||||
|
||||
for (const [requestId, request] of this.pendingRequests.entries()) {
|
||||
if (now - request.createdAt > expiredThreshold) {
|
||||
requestsToRemove.push(requestId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const requestId of requestsToRemove) {
|
||||
this.rejectPendingRequest(requestId, new Error('Request expired'));
|
||||
}
|
||||
|
||||
if (requestsToRemove.length > 0) {
|
||||
this.logger.info(`Cleaned up ${requestsToRemove.length} expired requests`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log connection statistics
|
||||
*/
|
||||
private logConnectionStats(): void {
|
||||
this.logger.info(
|
||||
`Connection Stats - Users: ${this.userToSessions.size}, Sessions: ${this.userSessions.size}, Connections: ${this.extensionConnections.size}, Pending Requests: ${this.pendingRequests.size}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session statistics
|
||||
*/
|
||||
getStats(): any {
|
||||
return {
|
||||
totalUsers: this.userToSessions.size,
|
||||
totalSessions: this.userSessions.size,
|
||||
totalConnections: this.extensionConnections.size,
|
||||
activeConnections: this.getAllActiveConnections().length,
|
||||
pendingRequests: this.pendingRequests.size,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup resources
|
||||
*/
|
||||
destroy(): void {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
}
|
||||
|
||||
// Cancel all pending requests
|
||||
for (const [requestId, request] of this.pendingRequests.entries()) {
|
||||
clearTimeout(request.timeout);
|
||||
request.reject(new Error('Session manager destroyed'));
|
||||
}
|
||||
|
||||
this.pendingRequests.clear();
|
||||
this.extensionConnections.clear();
|
||||
this.userSessions.clear();
|
||||
this.sessionToConnection.clear();
|
||||
this.userToSessions.clear();
|
||||
}
|
||||
}
|
304
app/remote-server/src/server/user-auth.ts
Normal file
304
app/remote-server/src/server/user-auth.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import { Logger } from 'pino';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
export interface UserToken {
|
||||
userId: string;
|
||||
tokenId: string;
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
metadata: {
|
||||
userAgent?: string;
|
||||
ipAddress?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AuthResult {
|
||||
success: boolean;
|
||||
userId?: string;
|
||||
sessionId?: string;
|
||||
token?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class UserAuthManager {
|
||||
private logger: Logger;
|
||||
private userTokens: Map<string, UserToken> = new Map(); // tokenId -> UserToken
|
||||
private userSessions: Map<string, Set<string>> = new Map(); // userId -> Set<tokenId>
|
||||
private tokenCleanupInterval: NodeJS.Timeout;
|
||||
|
||||
constructor(logger: Logger) {
|
||||
this.logger = logger;
|
||||
|
||||
// Start token cleanup interval
|
||||
this.tokenCleanupInterval = setInterval(() => {
|
||||
this.cleanupExpiredTokens();
|
||||
}, 60000); // Check every minute
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new user authentication token
|
||||
*/
|
||||
generateUserToken(metadata: any = {}): AuthResult {
|
||||
const userId = `user_${randomUUID()}`;
|
||||
const tokenId = `token_${randomUUID()}`;
|
||||
const now = Date.now();
|
||||
const expiresAt = now + (24 * 60 * 60 * 1000); // 24 hours
|
||||
|
||||
const userToken: UserToken = {
|
||||
userId,
|
||||
tokenId,
|
||||
createdAt: now,
|
||||
expiresAt,
|
||||
metadata: {
|
||||
userAgent: metadata.userAgent,
|
||||
ipAddress: metadata.ipAddress,
|
||||
...metadata
|
||||
}
|
||||
};
|
||||
|
||||
// Store token
|
||||
this.userTokens.set(tokenId, userToken);
|
||||
|
||||
// Track user sessions
|
||||
if (!this.userSessions.has(userId)) {
|
||||
this.userSessions.set(userId, new Set());
|
||||
}
|
||||
this.userSessions.get(userId)!.add(tokenId);
|
||||
|
||||
this.logger.info(`Generated user token - User: ${userId}, Token: ${tokenId}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
userId,
|
||||
token: tokenId,
|
||||
sessionId: `session_${userId}_${Date.now()}`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a user token
|
||||
*/
|
||||
validateToken(tokenId: string): AuthResult {
|
||||
const userToken = this.userTokens.get(tokenId);
|
||||
|
||||
if (!userToken) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid token'
|
||||
};
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
if (Date.now() > userToken.expiresAt) {
|
||||
this.revokeToken(tokenId);
|
||||
return {
|
||||
success: false,
|
||||
error: 'Token expired'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
userId: userToken.userId,
|
||||
sessionId: `session_${userToken.userId}_${userToken.createdAt}`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh a user token (extend expiration)
|
||||
*/
|
||||
refreshToken(tokenId: string): AuthResult {
|
||||
const userToken = this.userTokens.get(tokenId);
|
||||
|
||||
if (!userToken) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid token'
|
||||
};
|
||||
}
|
||||
|
||||
// Extend expiration by 24 hours
|
||||
userToken.expiresAt = Date.now() + (24 * 60 * 60 * 1000);
|
||||
|
||||
this.logger.info(`Refreshed token: ${tokenId} for user: ${userToken.userId}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
userId: userToken.userId,
|
||||
token: tokenId,
|
||||
sessionId: `session_${userToken.userId}_${userToken.createdAt}`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a user token
|
||||
*/
|
||||
revokeToken(tokenId: string): boolean {
|
||||
const userToken = this.userTokens.get(tokenId);
|
||||
|
||||
if (!userToken) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Remove from user sessions
|
||||
const userSessions = this.userSessions.get(userToken.userId);
|
||||
if (userSessions) {
|
||||
userSessions.delete(tokenId);
|
||||
if (userSessions.size === 0) {
|
||||
this.userSessions.delete(userToken.userId);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove token
|
||||
this.userTokens.delete(tokenId);
|
||||
|
||||
this.logger.info(`Revoked token: ${tokenId} for user: ${userToken.userId}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke all tokens for a user
|
||||
*/
|
||||
revokeUserTokens(userId: string): number {
|
||||
const userSessions = this.userSessions.get(userId);
|
||||
|
||||
if (!userSessions) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let revokedCount = 0;
|
||||
for (const tokenId of userSessions) {
|
||||
if (this.userTokens.delete(tokenId)) {
|
||||
revokedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
this.userSessions.delete(userId);
|
||||
|
||||
this.logger.info(`Revoked ${revokedCount} tokens for user: ${userId}`);
|
||||
return revokedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user information by token
|
||||
*/
|
||||
getUserInfo(tokenId: string): UserToken | null {
|
||||
return this.userTokens.get(tokenId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active tokens for a user
|
||||
*/
|
||||
getUserTokens(userId: string): UserToken[] {
|
||||
const userSessions = this.userSessions.get(userId);
|
||||
|
||||
if (!userSessions) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const tokens: UserToken[] = [];
|
||||
for (const tokenId of userSessions) {
|
||||
const token = this.userTokens.get(tokenId);
|
||||
if (token) {
|
||||
tokens.push(token);
|
||||
}
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract user ID from session ID
|
||||
*/
|
||||
extractUserIdFromSession(sessionId: string): string | null {
|
||||
// Session format: session_{userId}_{timestamp}
|
||||
const match = sessionId.match(/^session_(.+?)_\d+$/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create anonymous user session (no token required)
|
||||
*/
|
||||
createAnonymousSession(metadata: any = {}): AuthResult {
|
||||
const userId = `anon_${randomUUID()}`;
|
||||
const sessionId = `session_${userId}_${Date.now()}`;
|
||||
|
||||
this.logger.info(`Created anonymous session - User: ${userId}, Session: ${sessionId}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
userId,
|
||||
sessionId
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired tokens
|
||||
*/
|
||||
private cleanupExpiredTokens(): void {
|
||||
const now = Date.now();
|
||||
const tokensToRemove: string[] = [];
|
||||
|
||||
for (const [tokenId, userToken] of this.userTokens.entries()) {
|
||||
if (now > userToken.expiresAt) {
|
||||
tokensToRemove.push(tokenId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const tokenId of tokensToRemove) {
|
||||
this.revokeToken(tokenId);
|
||||
}
|
||||
|
||||
if (tokensToRemove.length > 0) {
|
||||
this.logger.info(`Cleaned up ${tokensToRemove.length} expired tokens`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get authentication statistics
|
||||
*/
|
||||
getAuthStats(): any {
|
||||
return {
|
||||
totalTokens: this.userTokens.size,
|
||||
totalUsers: this.userSessions.size,
|
||||
activeTokens: Array.from(this.userTokens.values()).filter(token => Date.now() < token.expiresAt).length
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate request from headers
|
||||
*/
|
||||
authenticateRequest(headers: any): AuthResult {
|
||||
// Try to get token from Authorization header
|
||||
const authHeader = headers.authorization || headers.Authorization;
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
const token = authHeader.substring(7);
|
||||
return this.validateToken(token);
|
||||
}
|
||||
|
||||
// Try to get token from custom header
|
||||
const tokenHeader = headers['x-auth-token'] || headers['X-Auth-Token'];
|
||||
if (tokenHeader) {
|
||||
return this.validateToken(tokenHeader);
|
||||
}
|
||||
|
||||
// Create anonymous session if no token provided
|
||||
return this.createAnonymousSession({
|
||||
userAgent: headers['user-agent'],
|
||||
ipAddress: headers['x-forwarded-for'] || 'unknown'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup resources
|
||||
*/
|
||||
destroy(): void {
|
||||
if (this.tokenCleanupInterval) {
|
||||
clearInterval(this.tokenCleanupInterval);
|
||||
}
|
||||
|
||||
this.userTokens.clear();
|
||||
this.userSessions.clear();
|
||||
}
|
||||
}
|
62
app/remote-server/test-chrome-connection.js
Normal file
62
app/remote-server/test-chrome-connection.js
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Test Chrome extension connection to remote server
|
||||
*/
|
||||
|
||||
import WebSocket from 'ws';
|
||||
|
||||
const CHROME_ENDPOINT = 'ws://localhost:3001/chrome';
|
||||
|
||||
async function testChromeConnection() {
|
||||
console.log('🔌 Testing Chrome extension connection...');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(CHROME_ENDPOINT);
|
||||
|
||||
ws.on('open', () => {
|
||||
console.log('✅ Connected to Chrome extension endpoint');
|
||||
|
||||
// Send a test message to see if any Chrome extensions are connected
|
||||
const testMessage = {
|
||||
id: 'test-' + Date.now(),
|
||||
action: 'callTool',
|
||||
params: {
|
||||
name: 'chrome_navigate',
|
||||
arguments: {
|
||||
url: 'https://www.google.com',
|
||||
newWindow: false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
console.log('📤 Sending test message:', JSON.stringify(testMessage, null, 2));
|
||||
ws.send(JSON.stringify(testMessage));
|
||||
|
||||
// Set a timeout to close the connection
|
||||
setTimeout(() => {
|
||||
console.log('⏰ Test timeout - closing connection');
|
||||
ws.close();
|
||||
resolve('Test completed');
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const response = JSON.parse(data.toString());
|
||||
console.log('📨 Received response:', JSON.stringify(response, null, 2));
|
||||
} catch (error) {
|
||||
console.error('❌ Error parsing response:', error);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error('❌ WebSocket error:', error);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('🔌 Chrome extension connection closed');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
testChromeConnection().catch(console.error);
|
49
app/remote-server/test-client.js
Normal file
49
app/remote-server/test-client.js
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Simple test client to verify the remote server is working
|
||||
*/
|
||||
|
||||
import WebSocket from 'ws';
|
||||
|
||||
const SERVER_URL = 'ws://localhost:3001/mcp';
|
||||
|
||||
console.log('🔌 Connecting to MCP Remote Server...');
|
||||
|
||||
const ws = new WebSocket(SERVER_URL);
|
||||
|
||||
ws.on('open', () => {
|
||||
console.log('✅ Connected to remote server!');
|
||||
|
||||
// Test listing tools
|
||||
console.log('📋 Requesting available tools...');
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
method: 'tools/list',
|
||||
params: {},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
console.log('📨 Received response:', JSON.stringify(message, null, 2));
|
||||
} catch (error) {
|
||||
console.error('❌ Error parsing message:', error);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('🔌 Connection closed');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error('❌ WebSocket error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Close connection after 5 seconds
|
||||
setTimeout(() => {
|
||||
console.log('⏰ Closing connection...');
|
||||
ws.close();
|
||||
}, 5000);
|
51
app/remote-server/test-connection-status.js
Normal file
51
app/remote-server/test-connection-status.js
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Monitor Chrome extension connections to the remote server
|
||||
*/
|
||||
|
||||
import WebSocket from 'ws';
|
||||
|
||||
const CHROME_ENDPOINT = 'ws://localhost:3001/chrome';
|
||||
|
||||
function monitorConnections() {
|
||||
console.log('🔍 Monitoring Chrome extension connections...');
|
||||
console.log('📍 Endpoint:', CHROME_ENDPOINT);
|
||||
console.log('');
|
||||
console.log('Instructions:');
|
||||
console.log('1. Load the Chrome extension from: app/chrome-extension/.output/chrome-mv3');
|
||||
console.log('2. Open the extension popup to check connection status');
|
||||
console.log('3. Watch this monitor for connection events');
|
||||
console.log('');
|
||||
|
||||
const ws = new WebSocket(CHROME_ENDPOINT);
|
||||
|
||||
ws.on('open', () => {
|
||||
console.log('✅ Connected to Chrome extension endpoint');
|
||||
console.log('⏳ Waiting for Chrome extension to connect...');
|
||||
});
|
||||
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
console.log('📨 Received message from Chrome extension:', JSON.stringify(message, null, 2));
|
||||
} catch (error) {
|
||||
console.log('📨 Received raw message:', data.toString());
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error('❌ WebSocket error:', error);
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('🔌 Connection closed');
|
||||
});
|
||||
|
||||
// Keep the connection alive
|
||||
setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
console.log('💓 Connection still alive, waiting for Chrome extension...');
|
||||
}
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
monitorConnections();
|
25
app/remote-server/test-health.js
Normal file
25
app/remote-server/test-health.js
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Simple health check test
|
||||
*/
|
||||
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
const SERVER_URL = 'http://localhost:3001';
|
||||
|
||||
async function testHealth() {
|
||||
try {
|
||||
console.log('🔍 Testing health endpoint...');
|
||||
const response = await fetch(`${SERVER_URL}/health`);
|
||||
|
||||
console.log('Status:', response.status);
|
||||
console.log('Headers:', Object.fromEntries(response.headers.entries()));
|
||||
|
||||
const data = await response.json();
|
||||
console.log('Response:', data);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
testHealth();
|
230
app/remote-server/test-multi-user-livekit.js
Normal file
230
app/remote-server/test-multi-user-livekit.js
Normal file
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Test script for multi-user Chrome extension to LiveKit agent integration
|
||||
* This script simulates multiple Chrome extension connections and verifies
|
||||
* that LiveKit agents are automatically started for each user
|
||||
*/
|
||||
|
||||
import WebSocket from 'ws';
|
||||
|
||||
const SERVER_URL = 'ws://localhost:3001/chrome';
|
||||
const NUM_USERS = 3;
|
||||
|
||||
class TestChromeUser {
|
||||
constructor(userId) {
|
||||
this.userId = userId;
|
||||
this.ws = null;
|
||||
this.sessionInfo = null;
|
||||
this.connected = false;
|
||||
this.liveKitAgentStarted = false;
|
||||
}
|
||||
|
||||
async connect() {
|
||||
return new Promise((resolve, reject) => {
|
||||
console.log(`👤 User ${this.userId}: Connecting Chrome extension...`);
|
||||
|
||||
this.ws = new WebSocket(SERVER_URL);
|
||||
|
||||
this.ws.on('open', () => {
|
||||
console.log(`✅ User ${this.userId}: Chrome extension connected`);
|
||||
this.connected = true;
|
||||
|
||||
// Send connection info (simulating Chrome extension)
|
||||
const connectionInfo = {
|
||||
type: 'connection_info',
|
||||
userAgent: `TestChromeUser-${this.userId}`,
|
||||
timestamp: Date.now(),
|
||||
extensionId: `test-extension-${this.userId}`
|
||||
};
|
||||
|
||||
this.ws.send(JSON.stringify(connectionInfo));
|
||||
});
|
||||
|
||||
this.ws.on('message', (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
|
||||
if (message.type === 'session_info') {
|
||||
this.sessionInfo = message.sessionInfo;
|
||||
console.log(`📋 User ${this.userId}: Received session info:`, {
|
||||
userId: this.sessionInfo.userId,
|
||||
sessionId: this.sessionInfo.sessionId,
|
||||
connectionId: this.sessionInfo.connectionId
|
||||
});
|
||||
|
||||
// Check if LiveKit agent should be starting
|
||||
console.log(`🚀 User ${this.userId}: LiveKit agent should be starting for room: mcp-chrome-user-${this.sessionInfo.userId}`);
|
||||
this.liveKitAgentStarted = true;
|
||||
|
||||
resolve();
|
||||
} else {
|
||||
console.log(`📨 User ${this.userId}: Received message:`, message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ User ${this.userId}: Error parsing message:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.on('close', () => {
|
||||
console.log(`🔌 User ${this.userId}: Chrome extension disconnected`);
|
||||
this.connected = false;
|
||||
});
|
||||
|
||||
this.ws.on('error', (error) => {
|
||||
console.error(`❌ User ${this.userId}: Connection error:`, error);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
// Timeout after 10 seconds
|
||||
setTimeout(() => {
|
||||
if (!this.sessionInfo) {
|
||||
reject(new Error(`User ${this.userId}: Timeout waiting for session info`));
|
||||
}
|
||||
}, 10000);
|
||||
});
|
||||
}
|
||||
|
||||
async sendTestCommand() {
|
||||
if (!this.connected || !this.ws) {
|
||||
throw new Error(`User ${this.userId}: Not connected`);
|
||||
}
|
||||
|
||||
const testCommand = {
|
||||
action: 'callTool',
|
||||
params: {
|
||||
name: 'chrome_navigate',
|
||||
arguments: { url: `https://example.com?user=${this.userId}` }
|
||||
},
|
||||
id: `test_${this.userId}_${Date.now()}`
|
||||
};
|
||||
|
||||
console.log(`🌐 User ${this.userId}: Sending navigation command`);
|
||||
this.ws.send(JSON.stringify(testCommand));
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.ws) {
|
||||
console.log(`👋 User ${this.userId}: Disconnecting Chrome extension`);
|
||||
this.ws.close();
|
||||
}
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
return {
|
||||
userId: this.userId,
|
||||
connected: this.connected,
|
||||
sessionInfo: this.sessionInfo,
|
||||
liveKitAgentStarted: this.liveKitAgentStarted,
|
||||
expectedRoom: this.sessionInfo ? `mcp-chrome-user-${this.sessionInfo.userId}` : null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function testMultiUserLiveKitIntegration() {
|
||||
console.log('🚀 Testing Multi-User Chrome Extension to LiveKit Agent Integration\n');
|
||||
console.log(`📊 Creating ${NUM_USERS} simulated Chrome extension users...\n`);
|
||||
|
||||
const users = [];
|
||||
|
||||
try {
|
||||
// Create and connect multiple users
|
||||
for (let i = 1; i <= NUM_USERS; i++) {
|
||||
const user = new TestChromeUser(i);
|
||||
users.push(user);
|
||||
|
||||
console.log(`\n--- Connecting User ${i} ---`);
|
||||
await user.connect();
|
||||
|
||||
// Wait a bit between connections to see the sequential startup
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
}
|
||||
|
||||
console.log('\n🎉 All Chrome extensions connected successfully!');
|
||||
|
||||
// Display session summary
|
||||
console.log('\n📊 SESSION AND LIVEKIT AGENT SUMMARY:');
|
||||
console.log('=' * 80);
|
||||
|
||||
users.forEach(user => {
|
||||
const status = user.getStatus();
|
||||
console.log(`👤 User ${status.userId}:`);
|
||||
console.log(` 📋 Session ID: ${status.sessionInfo?.sessionId || 'N/A'}`);
|
||||
console.log(` 🆔 User ID: ${status.sessionInfo?.userId || 'N/A'}`);
|
||||
console.log(` 🏠 Expected LiveKit Room: ${status.expectedRoom || 'N/A'}`);
|
||||
console.log(` 🚀 LiveKit Agent Started: ${status.liveKitAgentStarted ? '✅ YES' : '❌ NO'}`);
|
||||
console.log('');
|
||||
});
|
||||
|
||||
// Test sending commands from each user
|
||||
console.log('\n--- Testing Commands from Each User ---');
|
||||
for (const user of users) {
|
||||
await user.sendTestCommand();
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
// Wait for responses and LiveKit agent startup
|
||||
console.log('\n⏳ Waiting for LiveKit agents to start and process commands...');
|
||||
await new Promise(resolve => setTimeout(resolve, 10000));
|
||||
|
||||
// Test session isolation
|
||||
console.log('\n🔍 Session Isolation Test:');
|
||||
const sessionIds = users.map(user => user.sessionInfo?.sessionId).filter(Boolean);
|
||||
const userIds = users.map(user => user.sessionInfo?.userId).filter(Boolean);
|
||||
const uniqueSessionIds = new Set(sessionIds);
|
||||
const uniqueUserIds = new Set(userIds);
|
||||
|
||||
console.log(` Total users: ${users.length}`);
|
||||
console.log(` Unique session IDs: ${uniqueSessionIds.size}`);
|
||||
console.log(` Unique user IDs: ${uniqueUserIds.size}`);
|
||||
console.log(` Session isolation: ${uniqueSessionIds.size === users.length ? '✅ PASS' : '❌ FAIL'}`);
|
||||
console.log(` User ID isolation: ${uniqueUserIds.size === users.length ? '✅ PASS' : '❌ FAIL'}`);
|
||||
|
||||
// Test LiveKit room naming
|
||||
console.log('\n🏠 LiveKit Room Naming Test:');
|
||||
const expectedRooms = users.map(user => user.getStatus().expectedRoom).filter(Boolean);
|
||||
const uniqueRooms = new Set(expectedRooms);
|
||||
|
||||
console.log(` Expected rooms: ${expectedRooms.length}`);
|
||||
console.log(` Unique rooms: ${uniqueRooms.size}`);
|
||||
console.log(` Room isolation: ${uniqueRooms.size === users.length ? '✅ PASS' : '❌ FAIL'}`);
|
||||
|
||||
expectedRooms.forEach((room, index) => {
|
||||
console.log(` User ${index + 1} → Room: ${room}`);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Test failed:', error);
|
||||
} finally {
|
||||
// Clean up connections
|
||||
console.log('\n🧹 Cleaning up connections...');
|
||||
users.forEach(user => user.disconnect());
|
||||
|
||||
setTimeout(() => {
|
||||
console.log('\n✅ Multi-user LiveKit integration test completed');
|
||||
console.log('\n📝 Expected Results:');
|
||||
console.log(' - Each Chrome extension gets a unique session ID');
|
||||
console.log(' - Each user gets a unique LiveKit room (mcp-chrome-user-{userId})');
|
||||
console.log(' - LiveKit agents start automatically for each Chrome connection');
|
||||
console.log(' - Commands are routed to the correct user\'s Chrome extension');
|
||||
console.log(' - LiveKit agents stop when Chrome extensions disconnect');
|
||||
|
||||
process.exit(0);
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if server is running
|
||||
console.log('🔍 Checking if remote server is running...');
|
||||
const testWs = new WebSocket(SERVER_URL);
|
||||
|
||||
testWs.on('open', () => {
|
||||
testWs.close();
|
||||
console.log('✅ Remote server is running, starting multi-user LiveKit test...\n');
|
||||
testMultiUserLiveKitIntegration();
|
||||
});
|
||||
|
||||
testWs.on('error', (error) => {
|
||||
console.error('❌ Cannot connect to remote server. Please start the remote server first:');
|
||||
console.error(' cd app/remote-server && npm start');
|
||||
console.error('\nError:', error.message);
|
||||
process.exit(1);
|
||||
});
|
219
app/remote-server/test-multi-user.js
Normal file
219
app/remote-server/test-multi-user.js
Normal file
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* Test script for multi-user session management
|
||||
* This script simulates multiple Chrome extension connections to test session isolation
|
||||
*/
|
||||
|
||||
import WebSocket from 'ws';
|
||||
|
||||
const SERVER_URL = 'ws://localhost:3001/chrome';
|
||||
const NUM_CONNECTIONS = 3;
|
||||
|
||||
class TestConnection {
|
||||
constructor(id) {
|
||||
this.id = id;
|
||||
this.ws = null;
|
||||
this.sessionInfo = null;
|
||||
this.connected = false;
|
||||
}
|
||||
|
||||
async connect() {
|
||||
return new Promise((resolve, reject) => {
|
||||
console.log(`🔌 Connection ${this.id}: Connecting to ${SERVER_URL}`);
|
||||
|
||||
this.ws = new WebSocket(SERVER_URL);
|
||||
|
||||
this.ws.on('open', () => {
|
||||
console.log(`✅ Connection ${this.id}: Connected`);
|
||||
this.connected = true;
|
||||
|
||||
// Send connection info
|
||||
const connectionInfo = {
|
||||
type: 'connection_info',
|
||||
userAgent: `TestAgent-${this.id}`,
|
||||
timestamp: Date.now(),
|
||||
extensionId: `test-extension-${this.id}`
|
||||
};
|
||||
|
||||
this.ws.send(JSON.stringify(connectionInfo));
|
||||
});
|
||||
|
||||
this.ws.on('message', (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
|
||||
if (message.type === 'session_info') {
|
||||
this.sessionInfo = message.sessionInfo;
|
||||
console.log(`📋 Connection ${this.id}: Received session info:`, this.sessionInfo);
|
||||
resolve();
|
||||
} else {
|
||||
console.log(`📨 Connection ${this.id}: Received message:`, message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ Connection ${this.id}: Error parsing message:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.on('close', () => {
|
||||
console.log(`🔌 Connection ${this.id}: Disconnected`);
|
||||
this.connected = false;
|
||||
});
|
||||
|
||||
this.ws.on('error', (error) => {
|
||||
console.error(`❌ Connection ${this.id}: Error:`, error);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
// Timeout after 5 seconds
|
||||
setTimeout(() => {
|
||||
if (!this.sessionInfo) {
|
||||
reject(new Error(`Connection ${this.id}: Timeout waiting for session info`));
|
||||
}
|
||||
}, 5000);
|
||||
});
|
||||
}
|
||||
|
||||
async sendTestMessage() {
|
||||
if (!this.connected || !this.ws) {
|
||||
throw new Error(`Connection ${this.id}: Not connected`);
|
||||
}
|
||||
|
||||
const testMessage = {
|
||||
action: 'callTool',
|
||||
params: {
|
||||
name: 'chrome_navigate',
|
||||
arguments: { url: `https://example.com?user=${this.id}` }
|
||||
},
|
||||
id: `test_${this.id}_${Date.now()}`
|
||||
};
|
||||
|
||||
console.log(`📤 Connection ${this.id}: Sending test message:`, testMessage);
|
||||
this.ws.send(JSON.stringify(testMessage));
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function testMultiUserSessions() {
|
||||
console.log('🚀 Starting multi-user session test...\n');
|
||||
|
||||
const connections = [];
|
||||
|
||||
try {
|
||||
// Create and connect multiple test connections
|
||||
for (let i = 1; i <= NUM_CONNECTIONS; i++) {
|
||||
const connection = new TestConnection(i);
|
||||
connections.push(connection);
|
||||
|
||||
console.log(`\n--- Connecting User ${i} ---`);
|
||||
await connection.connect();
|
||||
|
||||
// Wait a bit between connections
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
console.log('\n🎉 All connections established successfully!');
|
||||
console.log('\n📊 Session Summary:');
|
||||
connections.forEach(conn => {
|
||||
console.log(` User ${conn.id}: Session ${conn.sessionInfo.sessionId}, User ID: ${conn.sessionInfo.userId}`);
|
||||
});
|
||||
|
||||
// Test sending messages from each connection
|
||||
console.log('\n--- Testing Message Routing ---');
|
||||
for (const connection of connections) {
|
||||
await connection.sendTestMessage();
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
// Wait for responses
|
||||
console.log('\n⏳ Waiting for responses...');
|
||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||
|
||||
// Test session isolation by checking unique session IDs
|
||||
const sessionIds = connections.map(conn => conn.sessionInfo.sessionId);
|
||||
const uniqueSessionIds = new Set(sessionIds);
|
||||
|
||||
console.log('\n🔍 Session Isolation Test:');
|
||||
console.log(` Total connections: ${connections.length}`);
|
||||
console.log(` Unique session IDs: ${uniqueSessionIds.size}`);
|
||||
console.log(` Session isolation: ${uniqueSessionIds.size === connections.length ? '✅ PASS' : '❌ FAIL'}`);
|
||||
|
||||
// Test user ID uniqueness
|
||||
const userIds = connections.map(conn => conn.sessionInfo.userId);
|
||||
const uniqueUserIds = new Set(userIds);
|
||||
|
||||
console.log(` Unique user IDs: ${uniqueUserIds.size}`);
|
||||
console.log(` User ID isolation: ${uniqueUserIds.size === connections.length ? '✅ PASS' : '❌ FAIL'}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Test failed:', error);
|
||||
} finally {
|
||||
// Clean up connections
|
||||
console.log('\n🧹 Cleaning up connections...');
|
||||
connections.forEach(conn => conn.disconnect());
|
||||
|
||||
setTimeout(() => {
|
||||
console.log('✅ Test completed');
|
||||
process.exit(0);
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
async function testSessionPersistence() {
|
||||
console.log('\n🔄 Testing session persistence...');
|
||||
|
||||
const connection = new TestConnection('persistence');
|
||||
|
||||
try {
|
||||
await connection.connect();
|
||||
const originalSessionId = connection.sessionInfo.sessionId;
|
||||
|
||||
console.log(`📋 Original session: ${originalSessionId}`);
|
||||
|
||||
// Disconnect and reconnect
|
||||
connection.disconnect();
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
await connection.connect();
|
||||
const newSessionId = connection.sessionInfo.sessionId;
|
||||
|
||||
console.log(`📋 New session: ${newSessionId}`);
|
||||
console.log(`🔄 Session persistence: ${originalSessionId === newSessionId ? '❌ FAIL (sessions should be different)' : '✅ PASS (new session created)'}`);
|
||||
|
||||
connection.disconnect();
|
||||
} catch (error) {
|
||||
console.error('❌ Session persistence test failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Run tests
|
||||
async function runAllTests() {
|
||||
try {
|
||||
await testMultiUserSessions();
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
await testSessionPersistence();
|
||||
} catch (error) {
|
||||
console.error('❌ Tests failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if server is running
|
||||
console.log('🔍 Checking if remote server is running...');
|
||||
const testWs = new WebSocket(SERVER_URL);
|
||||
|
||||
testWs.on('open', () => {
|
||||
testWs.close();
|
||||
console.log('✅ Server is running, starting tests...\n');
|
||||
runAllTests();
|
||||
});
|
||||
|
||||
testWs.on('error', (error) => {
|
||||
console.error('❌ Cannot connect to server. Please start the remote server first:');
|
||||
console.error(' cd app/remote-server && npm run dev');
|
||||
console.error('\nError:', error.message);
|
||||
process.exit(1);
|
||||
});
|
58
app/remote-server/test-simple-mcp.js
Normal file
58
app/remote-server/test-simple-mcp.js
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Simple MCP endpoint test
|
||||
*/
|
||||
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
const SERVER_URL = 'http://localhost:3001';
|
||||
|
||||
async function testMcpEndpoint() {
|
||||
try {
|
||||
console.log('🔍 Testing MCP endpoint with simple request...');
|
||||
|
||||
const initMessage = {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
clientInfo: {
|
||||
name: 'test-simple-mcp-client',
|
||||
version: '1.0.0',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
console.log('📤 Sending:', JSON.stringify(initMessage, null, 2));
|
||||
|
||||
const response = await fetch(`${SERVER_URL}/mcp`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json, text/event-stream',
|
||||
},
|
||||
body: JSON.stringify(initMessage),
|
||||
});
|
||||
|
||||
console.log('📥 Status:', response.status);
|
||||
console.log('📥 Headers:', Object.fromEntries(response.headers.entries()));
|
||||
|
||||
if (response.ok) {
|
||||
const sessionId = response.headers.get('mcp-session-id');
|
||||
console.log('🆔 Session ID:', sessionId);
|
||||
|
||||
const text = await response.text();
|
||||
console.log('📥 SSE Response:', text);
|
||||
} else {
|
||||
const text = await response.text();
|
||||
console.log('📥 Error response:', text);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
testMcpEndpoint();
|
85
app/remote-server/test-sse-client.js
Normal file
85
app/remote-server/test-sse-client.js
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Test client for SSE (Server-Sent Events) streaming connection
|
||||
*/
|
||||
|
||||
import { EventSource } from 'eventsource';
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
const SERVER_URL = 'http://localhost:3001';
|
||||
const SSE_URL = `${SERVER_URL}/sse`;
|
||||
const MESSAGES_URL = `${SERVER_URL}/messages`;
|
||||
|
||||
console.log('🔌 Testing SSE streaming connection...');
|
||||
|
||||
let sessionId = null;
|
||||
|
||||
// Create SSE connection
|
||||
const eventSource = new EventSource(SSE_URL);
|
||||
|
||||
eventSource.onopen = () => {
|
||||
console.log('✅ SSE connection established!');
|
||||
};
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log('📨 Received SSE message:', JSON.stringify(data, null, 2));
|
||||
|
||||
// Extract session ID from the first message
|
||||
if (data.sessionId && !sessionId) {
|
||||
sessionId = data.sessionId;
|
||||
console.log(`🆔 Session ID: ${sessionId}`);
|
||||
|
||||
// Test listing tools after connection is established
|
||||
setTimeout(() => testListTools(), 1000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('📨 Received SSE data:', event.data);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('❌ SSE error:', error);
|
||||
};
|
||||
|
||||
async function testListTools() {
|
||||
if (!sessionId) {
|
||||
console.error('❌ No session ID available');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('📋 Testing tools/list via SSE...');
|
||||
|
||||
const message = {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'tools/list',
|
||||
params: {},
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(MESSAGES_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Session-ID': sessionId,
|
||||
},
|
||||
body: JSON.stringify(message),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('❌ Failed to send message:', response.status, response.statusText);
|
||||
} else {
|
||||
console.log('✅ Message sent successfully');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error sending message:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Close connection after 10 seconds
|
||||
setTimeout(() => {
|
||||
console.log('⏰ Closing SSE connection...');
|
||||
eventSource.close();
|
||||
process.exit(0);
|
||||
}, 10000);
|
132
app/remote-server/test-streamable-http-client.js
Normal file
132
app/remote-server/test-streamable-http-client.js
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Test client for Streamable HTTP connection
|
||||
*/
|
||||
|
||||
import fetch from 'node-fetch';
|
||||
import { EventSource } from 'eventsource';
|
||||
|
||||
const SERVER_URL = 'http://localhost:3001';
|
||||
const MCP_URL = `${SERVER_URL}/mcp`;
|
||||
|
||||
console.log('🔌 Testing Streamable HTTP connection...');
|
||||
|
||||
let sessionId = null;
|
||||
|
||||
async function testStreamableHttp() {
|
||||
try {
|
||||
// Step 1: Send initialization request
|
||||
console.log('🚀 Sending initialization request...');
|
||||
|
||||
const initMessage = {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
clientInfo: {
|
||||
name: 'test-streamable-http-client',
|
||||
version: '1.0.0',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const initResponse = await fetch(MCP_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json, text/event-stream',
|
||||
},
|
||||
body: JSON.stringify(initMessage),
|
||||
});
|
||||
|
||||
if (!initResponse.ok) {
|
||||
throw new Error(`Initialization failed: ${initResponse.status} ${initResponse.statusText}`);
|
||||
}
|
||||
|
||||
// Extract session ID from response headers
|
||||
sessionId = initResponse.headers.get('mcp-session-id');
|
||||
console.log(`✅ Initialization successful! Session ID: ${sessionId}`);
|
||||
|
||||
const initResult = await initResponse.text();
|
||||
console.log('📨 Initialization response (SSE):', initResult);
|
||||
|
||||
// Step 2: Establish SSE stream for this session
|
||||
console.log('🔌 Establishing SSE stream...');
|
||||
|
||||
const eventSource = new EventSource(MCP_URL, {
|
||||
headers: {
|
||||
'MCP-Session-ID': sessionId,
|
||||
},
|
||||
});
|
||||
|
||||
eventSource.onopen = () => {
|
||||
console.log('✅ SSE stream established!');
|
||||
// Test listing tools after stream is ready
|
||||
setTimeout(() => testListTools(), 1000);
|
||||
};
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log('📨 Received streaming message:', JSON.stringify(data, null, 2));
|
||||
} catch (error) {
|
||||
console.log('📨 Received streaming data:', event.data);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('❌ SSE stream error:', error);
|
||||
};
|
||||
|
||||
// Close after 10 seconds
|
||||
setTimeout(() => {
|
||||
console.log('⏰ Closing connections...');
|
||||
eventSource.close();
|
||||
process.exit(0);
|
||||
}, 10000);
|
||||
} catch (error) {
|
||||
console.error('❌ Error in streamable HTTP test:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function testListTools() {
|
||||
if (!sessionId) {
|
||||
console.error('❌ No session ID available');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('📋 Testing tools/list via Streamable HTTP...');
|
||||
|
||||
const message = {
|
||||
jsonrpc: '2.0',
|
||||
id: 2,
|
||||
method: 'tools/list',
|
||||
params: {},
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(MCP_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'MCP-Session-ID': sessionId,
|
||||
},
|
||||
body: JSON.stringify(message),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('❌ Failed to send tools/list:', response.status, response.statusText);
|
||||
} else {
|
||||
console.log('✅ tools/list message sent successfully');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error sending tools/list:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Start the test
|
||||
testStreamableHttp();
|
77
app/remote-server/test-tool-call.js
Normal file
77
app/remote-server/test-tool-call.js
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Test tool call to verify Chrome extension connection using MCP WebSocket
|
||||
*/
|
||||
|
||||
import WebSocket from 'ws';
|
||||
|
||||
const MCP_SERVER_URL = 'ws://localhost:3001/ws/mcp';
|
||||
|
||||
async function testToolCall() {
|
||||
console.log('🔌 Testing tool call via MCP WebSocket...');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(MCP_SERVER_URL);
|
||||
|
||||
ws.on('open', () => {
|
||||
console.log('✅ Connected to MCP WebSocket');
|
||||
|
||||
// Send a proper MCP tool call
|
||||
const message = {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'chrome_navigate',
|
||||
arguments: {
|
||||
url: 'https://www.google.com',
|
||||
newWindow: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
console.log('📤 Sending MCP message:', JSON.stringify(message, null, 2));
|
||||
ws.send(JSON.stringify(message));
|
||||
});
|
||||
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const response = JSON.parse(data.toString());
|
||||
console.log('📨 MCP Response:', JSON.stringify(response, null, 2));
|
||||
|
||||
if (response.error) {
|
||||
console.error('❌ Tool call failed:', response.error);
|
||||
reject(new Error(response.error.message || response.error));
|
||||
} else if (response.result) {
|
||||
console.log('✅ Tool call successful!');
|
||||
resolve(response.result);
|
||||
} else {
|
||||
console.log('📨 Received other message:', response);
|
||||
}
|
||||
ws.close();
|
||||
} catch (error) {
|
||||
console.error('❌ Error parsing response:', error);
|
||||
reject(error);
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error('❌ WebSocket error:', error);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('🔌 WebSocket connection closed');
|
||||
});
|
||||
|
||||
// Timeout after 10 seconds
|
||||
setTimeout(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.close();
|
||||
reject(new Error('Test timeout'));
|
||||
}
|
||||
}, 10000);
|
||||
});
|
||||
}
|
||||
|
||||
testToolCall().catch(console.error);
|
112
app/remote-server/test-tools-list.js
Normal file
112
app/remote-server/test-tools-list.js
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Test tools/list via streamable HTTP
|
||||
*/
|
||||
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
const SERVER_URL = 'http://localhost:3001';
|
||||
const MCP_URL = `${SERVER_URL}/mcp`;
|
||||
|
||||
async function testToolsList() {
|
||||
try {
|
||||
console.log('🔍 Testing tools/list via streamable HTTP...');
|
||||
|
||||
// Step 1: Initialize session
|
||||
const initMessage = {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {
|
||||
tools: {}
|
||||
},
|
||||
clientInfo: {
|
||||
name: 'test-tools-list-client',
|
||||
version: '1.0.0'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
console.log('🚀 Step 1: Initializing session...');
|
||||
const initResponse = await fetch(MCP_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json, text/event-stream'
|
||||
},
|
||||
body: JSON.stringify(initMessage)
|
||||
});
|
||||
|
||||
if (!initResponse.ok) {
|
||||
throw new Error(`Initialization failed: ${initResponse.status} ${initResponse.statusText}`);
|
||||
}
|
||||
|
||||
const sessionId = initResponse.headers.get('mcp-session-id');
|
||||
console.log(`✅ Session initialized! Session ID: ${sessionId}`);
|
||||
|
||||
// Step 2: Send tools/list request
|
||||
const toolsListMessage = {
|
||||
jsonrpc: '2.0',
|
||||
id: 2,
|
||||
method: 'tools/list',
|
||||
params: {}
|
||||
};
|
||||
|
||||
console.log('📋 Step 2: Requesting tools list...');
|
||||
const toolsResponse = await fetch(MCP_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json, text/event-stream',
|
||||
'MCP-Session-ID': sessionId
|
||||
},
|
||||
body: JSON.stringify(toolsListMessage)
|
||||
});
|
||||
|
||||
if (!toolsResponse.ok) {
|
||||
throw new Error(`Tools list failed: ${toolsResponse.status} ${toolsResponse.statusText}`);
|
||||
}
|
||||
|
||||
const toolsResult = await toolsResponse.text();
|
||||
console.log('📋 Tools list response (SSE):', toolsResult);
|
||||
|
||||
// Step 3: Test a tool call (navigate_to_url)
|
||||
const toolCallMessage = {
|
||||
jsonrpc: '2.0',
|
||||
id: 3,
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'navigate_to_url',
|
||||
arguments: {
|
||||
url: 'https://example.com'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
console.log('🔧 Step 3: Testing tool call (navigate_to_url)...');
|
||||
const toolCallResponse = await fetch(MCP_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json, text/event-stream',
|
||||
'MCP-Session-ID': sessionId
|
||||
},
|
||||
body: JSON.stringify(toolCallMessage)
|
||||
});
|
||||
|
||||
if (!toolCallResponse.ok) {
|
||||
throw new Error(`Tool call failed: ${toolCallResponse.status} ${toolCallResponse.statusText}`);
|
||||
}
|
||||
|
||||
const toolCallResult = await toolCallResponse.text();
|
||||
console.log('🔧 Tool call response (SSE):', toolCallResult);
|
||||
|
||||
console.log('✅ All tests completed successfully!');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
testToolsList();
|
27
app/remote-server/tsconfig.json
Normal file
27
app/remote-server/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "node",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"resolveJsonModule": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"**/*.test.ts"
|
||||
]
|
||||
}
|
Reference in New Issue
Block a user