Major refactor: Multi-user Chrome MCP extension with remote server architecture

This commit is contained in:
nasir@endelospay.com
2025-08-21 20:09:57 +05:00
parent d97cad1736
commit 5d869f6a7c
125 changed files with 16249 additions and 11906 deletions

View File

@@ -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

View 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

View 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

View 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}`);
}
```

View File

@@ -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"
}
}

View File

@@ -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": "远程服务器错误状态"
}
}

View File

@@ -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

View 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;

View File

@@ -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,
};
};

View File

@@ -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;
}
}

View File

@@ -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) {

View File

@@ -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);
}
})();

View File

@@ -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';

View File

@@ -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) {

View File

@@ -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

View File

@@ -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,
);

View File

@@ -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

View File

@@ -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;
}

View 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;
}
});
}

View 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;
}
});
}

View 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()');

View File

@@ -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",

View File

@@ -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',

File diff suppressed because it is too large Load Diff

View File

@@ -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: [

View File

@@ -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
View 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.

View 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"
}
}

View 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);
});

View 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,
});
}
}

View 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`);
}
}
}

View 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');
}
}

View 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' };
}
}
}

View 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();
}
}

View 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();
}
}

View 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);

View 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);

View 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();

View 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();

View 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);
});

View 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);
});

View 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();

View 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);

View 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();

View 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);

View 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();

View 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"
]
}