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

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