Major refactor: Multi-user Chrome MCP extension with remote server architecture
This commit is contained in:
284
app/remote-server/README.md
Normal file
284
app/remote-server/README.md
Normal file
@@ -0,0 +1,284 @@
|
||||
# MCP Chrome Remote Server
|
||||
|
||||
A remote server implementation for the MCP Chrome Bridge that allows external applications to control Chrome through **direct WebSocket connections**.
|
||||
|
||||
## 🚀 New Direct Connection Architecture
|
||||
|
||||
This server now supports **direct connections** from Chrome extensions, eliminating the need for native messaging hosts as intermediaries:
|
||||
|
||||
- **Cherry Studio** → **Remote Server** (via Streamable HTTP)
|
||||
- **Chrome Extension** → **Remote Server** (via WebSocket)
|
||||
- **No Native Server Required** for Chrome extension communication
|
||||
|
||||
### Benefits
|
||||
|
||||
- ✅ Eliminates 10-second timeout errors
|
||||
- ✅ Faster response times
|
||||
- ✅ Simplified architecture
|
||||
- ✅ Better reliability
|
||||
- ✅ Easier debugging
|
||||
|
||||
## Features
|
||||
|
||||
- **Remote Control**: Control Chrome browser remotely via WebSocket API
|
||||
- **MCP Protocol**: Implements Model Context Protocol for tool-based interactions
|
||||
- **HTTP Streaming**: Full support for MCP Streamable HTTP and SSE (Server-Sent Events)
|
||||
- **Real-time Communication**: WebSocket-based real-time communication with Chrome extensions
|
||||
- **RESTful Health Checks**: HTTP endpoints for monitoring server health
|
||||
- **Extensible Architecture**: Easy to add new Chrome automation tools
|
||||
- **Session Management**: Robust session handling for streaming connections
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Install Dependencies (from project root)
|
||||
|
||||
```bash
|
||||
# Install all workspace dependencies
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### 2. Build the Server
|
||||
|
||||
```bash
|
||||
# From project root
|
||||
npm run build:remote
|
||||
|
||||
# Or from this directory
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 3. Start the Server
|
||||
|
||||
```bash
|
||||
# From project root (recommended)
|
||||
npm run start:server
|
||||
|
||||
# Or from this directory
|
||||
npm run start:server
|
||||
```
|
||||
|
||||
The server will start on `http://localhost:3001` by default.
|
||||
|
||||
### 4. Verify Server is Running
|
||||
|
||||
You should see output like:
|
||||
|
||||
```
|
||||
🚀 MCP Remote Server started successfully!
|
||||
📡 Server running at: http://0.0.0.0:3001
|
||||
🔌 WebSocket endpoint: ws://0.0.0.0:3001/ws/mcp
|
||||
🔌 Chrome extension endpoint: ws://0.0.0.0:3001/chrome
|
||||
🌊 Streaming HTTP endpoint: http://0.0.0.0:3001/mcp
|
||||
📡 SSE endpoint: http://0.0.0.0:3001/sse
|
||||
```
|
||||
|
||||
### 5. Test the Connection
|
||||
|
||||
```bash
|
||||
# Test WebSocket connection
|
||||
node test-client.js
|
||||
|
||||
# Test streaming HTTP connection
|
||||
node test-tools-list.js
|
||||
|
||||
# Test SSE connection
|
||||
node test-sse-client.js
|
||||
|
||||
# Test simple health check
|
||||
node test-health.js
|
||||
```
|
||||
|
||||
## Available Scripts
|
||||
|
||||
- `npm run start:server` - Build and start the production server
|
||||
- `npm run start` - Start the server (requires pre-built dist/)
|
||||
- `npm run dev` - Start development server with auto-reload
|
||||
- `npm run build` - Build TypeScript to JavaScript
|
||||
- `npm run test` - Run tests
|
||||
- `npm run lint` - Run ESLint
|
||||
- `npm run format` - Format code with Prettier
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- `PORT` - Server port (default: 3001)
|
||||
- `HOST` - Server host (default: 0.0.0.0)
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### HTTP Endpoints
|
||||
|
||||
- `GET /health` - Health check endpoint
|
||||
|
||||
### Streaming HTTP Endpoints (MCP Protocol)
|
||||
|
||||
- `POST /mcp` - Send MCP messages (initialization, tool calls, etc.)
|
||||
- `GET /mcp` - Establish SSE stream for receiving responses (requires MCP-Session-ID header)
|
||||
- `DELETE /mcp` - Terminate MCP session (requires MCP-Session-ID header)
|
||||
|
||||
### SSE Endpoints
|
||||
|
||||
- `GET /sse` - Server-Sent Events endpoint for MCP communication
|
||||
- `POST /messages` - Send messages to SSE session (requires X-Session-ID header)
|
||||
|
||||
### WebSocket Endpoints
|
||||
|
||||
- `WS /ws/mcp` - MCP protocol WebSocket endpoint for Chrome control
|
||||
- `WS /chrome` - Chrome extension WebSocket endpoint
|
||||
|
||||
## Available Tools
|
||||
|
||||
The server provides the following Chrome automation tools:
|
||||
|
||||
1. **navigate_to_url** - Navigate to a specific URL
|
||||
2. **get_page_content** - Get page text content
|
||||
3. **click_element** - Click on elements using CSS selectors
|
||||
4. **fill_input** - Fill input fields with text
|
||||
5. **take_screenshot** - Capture page screenshots
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Streamable HTTP Connection (Recommended)
|
||||
|
||||
```javascript
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
const SERVER_URL = 'http://localhost:3001';
|
||||
const MCP_URL = `${SERVER_URL}/mcp`;
|
||||
|
||||
// Step 1: Initialize session
|
||||
const initResponse = await fetch(MCP_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json, text/event-stream',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: { tools: {} },
|
||||
clientInfo: { name: 'my-client', version: '1.0.0' },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const sessionId = initResponse.headers.get('mcp-session-id');
|
||||
|
||||
// Step 2: Call tools
|
||||
const toolResponse = await fetch(MCP_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json, text/event-stream',
|
||||
'MCP-Session-ID': sessionId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 2,
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'navigate_to_url',
|
||||
arguments: { url: 'https://example.com' },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await toolResponse.text(); // SSE format
|
||||
```
|
||||
|
||||
### WebSocket Connection
|
||||
|
||||
```javascript
|
||||
const ws = new WebSocket('ws://localhost:3001/ws/mcp');
|
||||
|
||||
// Navigate to a URL
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'navigate_to_url',
|
||||
arguments: { url: 'https://example.com' },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Get page content
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'get_page_content',
|
||||
arguments: {},
|
||||
},
|
||||
}),
|
||||
);
|
||||
```
|
||||
|
||||
## Streaming Capabilities
|
||||
|
||||
The MCP Remote Server now supports multiple connection types:
|
||||
|
||||
### 1. **Streamable HTTP (Recommended)**
|
||||
|
||||
- Full MCP protocol compliance
|
||||
- Session-based communication
|
||||
- Server-Sent Events for real-time responses
|
||||
- Stateful connections with session management
|
||||
- Compatible with MCP clients like CherryStudio
|
||||
|
||||
### 2. **Server-Sent Events (SSE)**
|
||||
|
||||
- Real-time streaming communication
|
||||
- Lightweight alternative to WebSockets
|
||||
- HTTP-based with automatic reconnection
|
||||
|
||||
### 3. **WebSocket (Legacy)**
|
||||
|
||||
- Real-time bidirectional communication
|
||||
- Backward compatibility with existing clients
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────┐ HTTP/SSE ┌──────────────────┐ WebSocket ┌─────────────────┐
|
||||
│ MCP Client │ ◄──────────────► │ Remote Server │ ◄─────────────────► │ Chrome Extension │
|
||||
│ (External App) │ WebSocket │ (This Server) │ │ │
|
||||
└─────────────────┘ └──────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── index.ts # Main server entry point
|
||||
├── server/
|
||||
│ ├── mcp-remote-server.ts # MCP protocol implementation
|
||||
│ └── chrome-tools.ts # Chrome automation tools
|
||||
└── types/ # TypeScript type definitions
|
||||
```
|
||||
|
||||
### Adding New Tools
|
||||
|
||||
1. Add the tool definition in `mcp-remote-server.ts`
|
||||
2. Implement the tool logic in `chrome-tools.ts`
|
||||
3. Update the Chrome extension to handle new actions
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Server won't start**: Check if port 3000 is available
|
||||
2. **Chrome extension not connecting**: Ensure the extension is installed and enabled
|
||||
3. **WebSocket connection fails**: Check firewall settings and CORS configuration
|
||||
|
||||
### Logs
|
||||
|
||||
The server uses structured logging with Pino. Check console output for detailed error messages and debugging information.
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see LICENSE file for details.
|
51
app/remote-server/package.json
Normal file
51
app/remote-server/package.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "mcp-chrome-remote-server",
|
||||
"version": "1.0.0",
|
||||
"description": "Remote MCP Chrome Bridge Server",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node dist/index.js",
|
||||
"start:server": "npm run build && npm run start",
|
||||
"start:custom": "npm run build && PORT=8080 HOST=localhost npm run start",
|
||||
"dev": "nodemon --watch src --ext ts,js,json --ignore dist/ --exec \"npm run build && npm run start\"",
|
||||
"build": "tsc",
|
||||
"test": "jest",
|
||||
"lint": "eslint 'src/**/*.{js,ts}'",
|
||||
"lint:fix": "eslint 'src/**/*.{js,ts}' --fix",
|
||||
"format": "prettier --write 'src/**/*.{js,ts,json}'"
|
||||
},
|
||||
"keywords": [
|
||||
"mcp",
|
||||
"chrome",
|
||||
"remote",
|
||||
"server"
|
||||
],
|
||||
"author": "hangye",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.0.1",
|
||||
"@fastify/websocket": "^11.0.1",
|
||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||
"chalk": "^5.4.1",
|
||||
"chrome-mcp-shared": "workspace:*",
|
||||
"eventsource": "^4.0.0",
|
||||
"fastify": "^5.3.2",
|
||||
"node-fetch": "^3.3.2",
|
||||
"pino": "^9.6.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"uuid": "^11.1.0",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^22.15.3",
|
||||
"@types/ws": "^8.5.13",
|
||||
"@typescript-eslint/parser": "^8.31.1",
|
||||
"eslint": "^9.26.0",
|
||||
"jest": "^29.7.0",
|
||||
"nodemon": "^3.1.10",
|
||||
"prettier": "^3.5.3",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
487
app/remote-server/src/index.ts
Normal file
487
app/remote-server/src/index.ts
Normal file
@@ -0,0 +1,487 @@
|
||||
import Fastify from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import websocket from '@fastify/websocket';
|
||||
import { pino } from 'pino';
|
||||
import chalk from 'chalk';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import { MCPRemoteServer } from './server/mcp-remote-server.js';
|
||||
|
||||
const logger = pino({
|
||||
level: 'info',
|
||||
});
|
||||
|
||||
async function startServer() {
|
||||
const fastify = Fastify({
|
||||
logger: true,
|
||||
});
|
||||
|
||||
// Register CORS
|
||||
await fastify.register(cors, {
|
||||
origin: true,
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
// Register WebSocket support
|
||||
await fastify.register(websocket);
|
||||
|
||||
// Create MCP Remote Server instance
|
||||
const mcpServer = new MCPRemoteServer(logger);
|
||||
|
||||
// Transport mapping for streaming connections
|
||||
const transportsMap: Map<string, StreamableHTTPServerTransport | SSEServerTransport> = new Map();
|
||||
|
||||
// Health check endpoint
|
||||
fastify.get('/health', async (request, reply) => {
|
||||
return { status: 'ok', timestamp: new Date().toISOString() };
|
||||
});
|
||||
|
||||
// SSE endpoint for streaming MCP communication
|
||||
fastify.get('/sse', async (request, reply) => {
|
||||
try {
|
||||
// Set SSE headers
|
||||
reply.raw.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'Cache-Control',
|
||||
});
|
||||
|
||||
// Create SSE transport
|
||||
const transport = new SSEServerTransport('/messages', reply.raw);
|
||||
transportsMap.set(transport.sessionId, transport);
|
||||
|
||||
reply.raw.on('close', () => {
|
||||
transportsMap.delete(transport.sessionId);
|
||||
logger.info(`SSE connection closed for session: ${transport.sessionId}`);
|
||||
});
|
||||
|
||||
// Start the transport first
|
||||
await transport.start();
|
||||
|
||||
// Connect the MCP server to this transport
|
||||
await mcpServer.connectTransport(transport);
|
||||
|
||||
// Hijack the reply to prevent Fastify from sending additional headers
|
||||
reply.hijack();
|
||||
} catch (error) {
|
||||
logger.error('Error setting up SSE connection:', error);
|
||||
if (!reply.sent && !reply.raw.headersSent) {
|
||||
reply.code(500).send({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// POST /messages: Handle SSE POST messages
|
||||
fastify.post('/messages', async (request, reply) => {
|
||||
const sessionId = request.headers['x-session-id'] as string | undefined;
|
||||
const transport = sessionId ? (transportsMap.get(sessionId) as SSEServerTransport) : undefined;
|
||||
|
||||
if (!transport) {
|
||||
reply.code(400).send({ error: 'Invalid session ID for SSE' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await transport.handlePostMessage(request.raw, reply.raw, request.body);
|
||||
} catch (error) {
|
||||
logger.error('Error handling SSE POST message:', error);
|
||||
if (!reply.sent) {
|
||||
reply.code(500).send({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// POST /mcp: Handle client-to-server messages for streamable HTTP
|
||||
fastify.post('/mcp', async (request, reply) => {
|
||||
// Extract session ID and user ID from headers for routing
|
||||
const sessionId = request.headers['mcp-session-id'] as string | undefined;
|
||||
const userId = request.headers['chrome-user-id'] as string | undefined;
|
||||
let transport: StreamableHTTPServerTransport | undefined = transportsMap.get(
|
||||
sessionId || '',
|
||||
) as StreamableHTTPServerTransport;
|
||||
|
||||
if (transport) {
|
||||
// Transport found, use existing one
|
||||
} else if (!sessionId && isInitializeRequest(request.body)) {
|
||||
// Create new session for initialization request
|
||||
const newSessionId = randomUUID();
|
||||
transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => newSessionId,
|
||||
onsessioninitialized: (initializedSessionId) => {
|
||||
if (transport && initializedSessionId === newSessionId) {
|
||||
transportsMap.set(initializedSessionId, transport);
|
||||
logger.info(`New streamable HTTP session initialized: ${initializedSessionId}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Connect the MCP server to this transport
|
||||
await mcpServer.connectTransport(transport);
|
||||
} else {
|
||||
reply.code(400).send({ error: 'Invalid session or missing initialization' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Set user context for routing if user ID is provided
|
||||
if (userId) {
|
||||
mcpServer.setUserContext(userId, sessionId);
|
||||
logger.info(
|
||||
`🎯 [MCP] User context set for request - User: ${userId}, Session: ${sessionId}`,
|
||||
);
|
||||
}
|
||||
|
||||
await transport.handleRequest(request.raw, reply.raw, request.body);
|
||||
if (!reply.sent) {
|
||||
reply.hijack(); // Prevent Fastify from automatically sending response
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error handling streamable HTTP POST request:', error);
|
||||
if (!reply.sent) {
|
||||
reply.code(500).send({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// GET /mcp: Handle SSE stream for streamable HTTP
|
||||
fastify.get('/mcp', async (request, reply) => {
|
||||
const sessionId = request.headers['mcp-session-id'] as string | undefined;
|
||||
const transport = sessionId
|
||||
? (transportsMap.get(sessionId) as StreamableHTTPServerTransport)
|
||||
: undefined;
|
||||
|
||||
if (!transport) {
|
||||
reply.code(400).send({ error: 'Invalid session ID' });
|
||||
return;
|
||||
}
|
||||
|
||||
reply.raw.setHeader('Content-Type', 'text/event-stream');
|
||||
reply.raw.setHeader('Cache-Control', 'no-cache');
|
||||
reply.raw.setHeader('Connection', 'keep-alive');
|
||||
reply.raw.setHeader('Access-Control-Allow-Origin', '*');
|
||||
reply.raw.flushHeaders();
|
||||
|
||||
try {
|
||||
await transport.handleRequest(request.raw, reply.raw);
|
||||
if (!reply.sent) {
|
||||
reply.hijack(); // Prevent Fastify from automatically sending response
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error handling streamable HTTP GET request:', error);
|
||||
if (!reply.raw.writableEnded) {
|
||||
reply.raw.end();
|
||||
}
|
||||
}
|
||||
|
||||
request.socket.on('close', () => {
|
||||
logger.info(`Streamable HTTP client disconnected for session: ${sessionId}`);
|
||||
});
|
||||
});
|
||||
|
||||
// DELETE /mcp: Handle session termination for streamable HTTP
|
||||
fastify.delete('/mcp', async (request, reply) => {
|
||||
const sessionId = request.headers['mcp-session-id'] as string | undefined;
|
||||
const transport = sessionId
|
||||
? (transportsMap.get(sessionId) as StreamableHTTPServerTransport)
|
||||
: undefined;
|
||||
|
||||
if (!transport) {
|
||||
reply.code(400).send({ error: 'Invalid session ID' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await transport.handleRequest(request.raw, reply.raw);
|
||||
if (sessionId) {
|
||||
transportsMap.delete(sessionId);
|
||||
logger.info(`Streamable HTTP session terminated: ${sessionId}`);
|
||||
}
|
||||
|
||||
if (!reply.sent) {
|
||||
reply.code(204).send();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error handling streamable HTTP DELETE request:', error);
|
||||
if (!reply.sent) {
|
||||
reply.code(500).send({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// WebSocket endpoint for MCP communication
|
||||
fastify.register(async function (fastify) {
|
||||
fastify.get('/ws/mcp', { websocket: true }, (connection: any, req) => {
|
||||
logger.info('New MCP WebSocket connection established');
|
||||
|
||||
// Set up ping/pong to keep connection alive
|
||||
const pingInterval = setInterval(() => {
|
||||
if (connection.readyState === connection.OPEN) {
|
||||
connection.ping();
|
||||
}
|
||||
}, 30000); // Ping every 30 seconds
|
||||
|
||||
connection.on('pong', () => {
|
||||
logger.debug('Received pong from MCP client');
|
||||
});
|
||||
|
||||
connection.on('message', async (message: any) => {
|
||||
try {
|
||||
const data = JSON.parse(message.toString());
|
||||
console.log('🔵 [MCP WebSocket] Received message:', JSON.stringify(data, null, 2));
|
||||
logger.info('🔵 [MCP WebSocket] Received message:', {
|
||||
method: data.method,
|
||||
id: data.id,
|
||||
params: data.params,
|
||||
fullMessage: data,
|
||||
});
|
||||
|
||||
// Handle MCP protocol messages with proper ID preservation
|
||||
let response;
|
||||
|
||||
if (data.method === 'tools/list') {
|
||||
try {
|
||||
const toolsResponse = await mcpServer.handleMessage(data);
|
||||
response = {
|
||||
jsonrpc: '2.0',
|
||||
id: data.id,
|
||||
result: toolsResponse,
|
||||
};
|
||||
} catch (error) {
|
||||
response = {
|
||||
jsonrpc: '2.0',
|
||||
id: data.id,
|
||||
error: {
|
||||
code: -32603,
|
||||
message: error instanceof Error ? error.message : 'Internal error',
|
||||
},
|
||||
};
|
||||
}
|
||||
} else if (data.method === 'tools/call') {
|
||||
try {
|
||||
const toolResponse = await mcpServer.handleMessage(data);
|
||||
response = {
|
||||
jsonrpc: '2.0',
|
||||
id: data.id,
|
||||
result: toolResponse,
|
||||
};
|
||||
} catch (error) {
|
||||
response = {
|
||||
jsonrpc: '2.0',
|
||||
id: data.id,
|
||||
error: {
|
||||
code: -32603,
|
||||
message: error instanceof Error ? error.message : 'Tool execution failed',
|
||||
},
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// Unknown method
|
||||
response = {
|
||||
jsonrpc: '2.0',
|
||||
id: data.id,
|
||||
error: {
|
||||
code: -32601,
|
||||
message: 'Method not found',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (response) {
|
||||
logger.info('🟢 [MCP WebSocket] Sending response:', {
|
||||
id: response.id,
|
||||
hasResult: !!response.result,
|
||||
hasError: !!response.error,
|
||||
fullResponse: response,
|
||||
});
|
||||
connection.send(JSON.stringify(response));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('🚨 [MCP WebSocket] Error handling MCP message:');
|
||||
console.error('Error message:', error instanceof Error ? error.message : String(error));
|
||||
console.error('Error stack:', error instanceof Error ? error.stack : undefined);
|
||||
console.error('Full error:', error);
|
||||
logger.error('🚨 [MCP WebSocket] Error handling MCP message:', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
errorType: typeof error,
|
||||
fullError: error,
|
||||
});
|
||||
// Send error response with proper MCP format
|
||||
const errorResponse = {
|
||||
jsonrpc: '2.0',
|
||||
id: null, // Use null if we can't parse the original ID
|
||||
error: {
|
||||
code: -32700,
|
||||
message: 'Parse error',
|
||||
},
|
||||
};
|
||||
connection.send(JSON.stringify(errorResponse));
|
||||
}
|
||||
});
|
||||
|
||||
connection.on('close', () => {
|
||||
logger.info('MCP WebSocket connection closed');
|
||||
clearInterval(pingInterval);
|
||||
});
|
||||
|
||||
connection.on('error', (error: any) => {
|
||||
logger.error('WebSocket error:', error);
|
||||
clearInterval(pingInterval);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// WebSocket endpoint for Chrome extension connections
|
||||
fastify.register(async function (fastify) {
|
||||
fastify.get('/chrome', { websocket: true }, (connection: any, req) => {
|
||||
logger.info('New Chrome extension WebSocket connection established');
|
||||
|
||||
// Set up ping/pong to keep Chrome extension connection alive
|
||||
const chromeExtensionPingInterval = setInterval(() => {
|
||||
if (connection.readyState === connection.OPEN) {
|
||||
connection.ping();
|
||||
}
|
||||
}, 30000); // Ping every 30 seconds
|
||||
|
||||
// Create a connection wrapper for the Chrome tools
|
||||
const connectionWrapper = {
|
||||
socket: connection,
|
||||
send: (data: string) => connection.send(data),
|
||||
on: (event: string, handler: Function) => connection.on(event, handler),
|
||||
off: (event: string, handler: Function) => connection.off(event, handler),
|
||||
get readyState() {
|
||||
// WebSocket states: 0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED
|
||||
return connection.readyState || 1; // Default to OPEN if not available
|
||||
},
|
||||
};
|
||||
|
||||
// Extract user information from connection headers or query params
|
||||
const userAgent = req.headers['user-agent'] || 'Unknown';
|
||||
const ipAddress = req.headers['x-forwarded-for'] || req.socket?.remoteAddress || 'Unknown';
|
||||
|
||||
// Initialize with temporary user ID (will be updated when Chrome extension sends connection_info)
|
||||
let currentUserId = `temp_user_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`;
|
||||
|
||||
// Register this connection with the Chrome tools with session management
|
||||
const sessionInfo = mcpServer.registerChromeExtension(connectionWrapper, currentUserId, {
|
||||
userAgent,
|
||||
ipAddress,
|
||||
connectedAt: new Date().toISOString(),
|
||||
connectionType: 'anonymous',
|
||||
});
|
||||
|
||||
logger.info('🟢 [Chrome Extension] Connection registered:', sessionInfo);
|
||||
|
||||
connection.on('message', async (message: any) => {
|
||||
try {
|
||||
const data = JSON.parse(message.toString());
|
||||
|
||||
// Handle connection info message
|
||||
if (data.type === 'connection_info') {
|
||||
logger.info('🔗 [Chrome Extension] Received connection info:', data);
|
||||
|
||||
// Update user ID if provided by Chrome extension
|
||||
if (data.userId && data.userId !== sessionInfo.userId) {
|
||||
logger.info(
|
||||
`🔄 [Chrome Extension] Updating user ID from ${sessionInfo.userId} to ${data.userId}`,
|
||||
);
|
||||
|
||||
// Update the session with the Chrome extension's user ID
|
||||
const updatedSessionInfo = mcpServer.updateChromeExtensionUserId(
|
||||
connectionWrapper,
|
||||
data.userId,
|
||||
);
|
||||
if (updatedSessionInfo) {
|
||||
// Update our local reference
|
||||
Object.assign(sessionInfo, updatedSessionInfo);
|
||||
logger.info(
|
||||
`✅ [Chrome Extension] User ID updated successfully: ${sessionInfo.userId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Send session info back to extension
|
||||
const sessionResponse = {
|
||||
type: 'session_info',
|
||||
sessionInfo: {
|
||||
userId: sessionInfo.userId,
|
||||
sessionId: sessionInfo.sessionId,
|
||||
connectionId: sessionInfo.connectionId,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
connection.send(JSON.stringify(sessionResponse));
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('🟡 [Chrome Extension] Received message:', {
|
||||
action: data.action,
|
||||
id: data.id,
|
||||
type: data.type,
|
||||
sessionId: sessionInfo.sessionId,
|
||||
userId: sessionInfo.userId,
|
||||
fullMessage: data,
|
||||
});
|
||||
|
||||
// Handle responses from Chrome extension
|
||||
mcpServer.handleChromeResponse(data);
|
||||
} catch (error) {
|
||||
logger.error('Error handling Chrome extension message:', error);
|
||||
}
|
||||
});
|
||||
|
||||
connection.on('close', () => {
|
||||
logger.info('Chrome extension WebSocket connection closed');
|
||||
clearInterval(chromeExtensionPingInterval);
|
||||
mcpServer.unregisterChromeExtension(connectionWrapper);
|
||||
});
|
||||
|
||||
connection.on('error', (error: any) => {
|
||||
logger.error('Chrome extension WebSocket error:', error);
|
||||
clearInterval(chromeExtensionPingInterval);
|
||||
mcpServer.unregisterChromeExtension(connectionWrapper);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Start the server
|
||||
const port = process.env.PORT ? parseInt(process.env.PORT) : 3001;
|
||||
const host = process.env.HOST || '0.0.0.0';
|
||||
|
||||
try {
|
||||
await fastify.listen({ port, host });
|
||||
console.log(chalk.green(`🚀 MCP Remote Server started successfully!`));
|
||||
console.log(chalk.blue(`📡 Server running at: http://${host}:${port}`));
|
||||
console.log(chalk.blue(`🌊 Streaming HTTP endpoint: http://${host}:${port}/mcp`));
|
||||
console.log(chalk.blue(`📡 SSE endpoint: http://${host}:${port}/sse`));
|
||||
console.log(chalk.blue(`🔌 WebSocket endpoint: ws://${host}:${port}/ws/mcp`));
|
||||
console.log(chalk.blue(`🔌 Chrome extension endpoint: ws://${host}:${port}/chrome`));
|
||||
console.log(chalk.yellow(`💡 Use 'npm run start:server' to start the server`));
|
||||
} catch (err) {
|
||||
console.error('Error starting server:', err);
|
||||
logger.error('Error starting server:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle graceful shutdown
|
||||
process.on('SIGINT', () => {
|
||||
console.log(chalk.yellow('\n🛑 Shutting down server...'));
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
console.log(chalk.yellow('\n🛑 Shutting down server...'));
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
startServer().catch((error) => {
|
||||
console.error('Failed to start server:', error);
|
||||
logger.error('Failed to start server:', error);
|
||||
process.exit(1);
|
||||
});
|
647
app/remote-server/src/server/chrome-tools.ts
Normal file
647
app/remote-server/src/server/chrome-tools.ts
Normal file
@@ -0,0 +1,647 @@
|
||||
import { Logger } from 'pino';
|
||||
import { TOOL_NAMES } from 'chrome-mcp-shared';
|
||||
import { SessionManager, ExtensionConnection } from './session-manager.js';
|
||||
import { ConnectionRouter, RouteResult } from './connection-router.js';
|
||||
import { LiveKitAgentManager } from './livekit-agent-manager.js';
|
||||
|
||||
export class ChromeTools {
|
||||
private logger: Logger;
|
||||
private sessionManager: SessionManager;
|
||||
private connectionRouter: ConnectionRouter;
|
||||
private liveKitAgentManager: LiveKitAgentManager;
|
||||
private currentUserId?: string;
|
||||
private currentSessionId?: string;
|
||||
|
||||
// Common URL mappings for natural language requests
|
||||
private urlMappings: Map<string, string> = new Map([
|
||||
['google', 'https://www.google.com'],
|
||||
['google.com', 'https://www.google.com'],
|
||||
['youtube', 'https://www.youtube.com'],
|
||||
['youtube.com', 'https://www.youtube.com'],
|
||||
['facebook', 'https://www.facebook.com'],
|
||||
['facebook.com', 'https://www.facebook.com'],
|
||||
['twitter', 'https://www.twitter.com'],
|
||||
['twitter.com', 'https://www.twitter.com'],
|
||||
['x.com', 'https://www.x.com'],
|
||||
['github', 'https://www.github.com'],
|
||||
['github.com', 'https://www.github.com'],
|
||||
['stackoverflow', 'https://www.stackoverflow.com'],
|
||||
['stackoverflow.com', 'https://www.stackoverflow.com'],
|
||||
['reddit', 'https://www.reddit.com'],
|
||||
['reddit.com', 'https://www.reddit.com'],
|
||||
['amazon', 'https://www.amazon.com'],
|
||||
['amazon.com', 'https://www.amazon.com'],
|
||||
['netflix', 'https://www.netflix.com'],
|
||||
['netflix.com', 'https://www.netflix.com'],
|
||||
['linkedin', 'https://www.linkedin.com'],
|
||||
['linkedin.com', 'https://www.linkedin.com'],
|
||||
['instagram', 'https://www.instagram.com'],
|
||||
['instagram.com', 'https://www.instagram.com'],
|
||||
]);
|
||||
|
||||
constructor(logger: Logger) {
|
||||
this.logger = logger;
|
||||
this.sessionManager = new SessionManager(logger);
|
||||
this.connectionRouter = new ConnectionRouter(logger, this.sessionManager);
|
||||
this.liveKitAgentManager = new LiveKitAgentManager(logger, this.sessionManager);
|
||||
}
|
||||
|
||||
// Register a Chrome extension connection with session management
|
||||
registerExtension(
|
||||
connection: any,
|
||||
userId?: string,
|
||||
metadata?: any,
|
||||
): { userId: string; sessionId: string; connectionId: string } {
|
||||
const result = this.sessionManager.registerExtensionConnection(connection, userId, metadata);
|
||||
this.logger.info(
|
||||
`🔗 Chrome extension connected - User: ${result.userId}, Session: ${result.sessionId}`,
|
||||
);
|
||||
|
||||
// Note: LiveKit agent is no longer started automatically on connection
|
||||
// Agents should be started manually when needed
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Unregister a Chrome extension connection
|
||||
unregisterExtension(connection: any): boolean {
|
||||
const result = this.sessionManager.unregisterExtensionConnection(connection);
|
||||
if (result) {
|
||||
this.logger.info('🔌 Chrome extension disconnected');
|
||||
// Note: LiveKit agent is no longer stopped automatically on disconnection
|
||||
// Agents should be managed manually when needed
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Update Chrome extension user ID
|
||||
updateExtensionUserId(connection: any, newUserId: string): any {
|
||||
const result = this.sessionManager.updateExtensionUserId(connection, newUserId);
|
||||
if (result) {
|
||||
this.logger.info(`🔄 Chrome extension user ID updated to: ${newUserId}`);
|
||||
// Note: LiveKit agent is no longer restarted automatically on user ID update
|
||||
// Agents should be managed manually when needed
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Set user context for routing
|
||||
setUserContext(userId: string, sessionId?: string) {
|
||||
this.currentUserId = userId;
|
||||
this.currentSessionId = sessionId;
|
||||
this.logger.info(`🎯 [Chrome Tools] User context set - User: ${userId}, Session: ${sessionId}`);
|
||||
}
|
||||
|
||||
// Handle responses from Chrome extension
|
||||
handleResponse(data: any) {
|
||||
const stats = this.sessionManager.getStats();
|
||||
this.logger.info(`📨 [Chrome Tools] Received response from Chrome extension:`, {
|
||||
messageId: data.id,
|
||||
hasResult: !!data.result,
|
||||
hasError: !!data.error,
|
||||
pendingRequestsCount: stats.pendingRequests,
|
||||
fullData: data,
|
||||
});
|
||||
|
||||
if (data.id) {
|
||||
if (data.error) {
|
||||
this.logger.error(`📨 [Chrome Tools] Chrome extension returned error: ${data.error}`);
|
||||
this.sessionManager.rejectPendingRequest(data.id, new Error(data.error));
|
||||
} else {
|
||||
this.logger.info(
|
||||
`📨 [Chrome Tools] Chrome extension returned success result:`,
|
||||
data.result,
|
||||
);
|
||||
this.sessionManager.resolvePendingRequest(data.id, data.result);
|
||||
}
|
||||
} else {
|
||||
// Filter out ping/heartbeat messages and other non-request messages to reduce noise
|
||||
const isPingMessage =
|
||||
data.type === 'ping' || (data.id && data.id.toString().startsWith('ping_'));
|
||||
const isHeartbeatMessage = !data.id || data.id === undefined;
|
||||
|
||||
if (!isPingMessage && !isHeartbeatMessage) {
|
||||
this.logger.warn(
|
||||
`📨 [Chrome Tools] Received response for unknown or expired request ID: ${data.id}`,
|
||||
);
|
||||
} else {
|
||||
// Log ping/heartbeat messages at debug level to reduce noise
|
||||
this.logger.debug(
|
||||
`📨 [Chrome Tools] Received ping/heartbeat message (ID: ${data.id}, type: ${data.type})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process natural language navigation requests
|
||||
private processNavigationRequest(args: any): any {
|
||||
if (!args || !args.url) {
|
||||
return args;
|
||||
}
|
||||
|
||||
const url = args.url.toLowerCase().trim();
|
||||
|
||||
// Check if it's a natural language request like "google", "open google", etc.
|
||||
const patterns = [/^(?:open\s+|go\s+to\s+|navigate\s+to\s+)?(.+?)(?:\.com)?$/i, /^(.+?)$/i];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = url.match(pattern);
|
||||
if (match) {
|
||||
const site = match[1].toLowerCase().trim();
|
||||
const mappedUrl = this.urlMappings.get(site);
|
||||
if (mappedUrl) {
|
||||
this.logger.info(`Mapped natural language request "${url}" to "${mappedUrl}"`);
|
||||
return { ...args, url: mappedUrl };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no mapping found, check if it's already a valid URL
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
// Try to make it a valid URL
|
||||
const processedUrl = url.includes('.')
|
||||
? `https://${url}`
|
||||
: `https://www.google.com/search?q=${encodeURIComponent(url)}`;
|
||||
this.logger.info(`Processed URL "${url}" to "${processedUrl}"`);
|
||||
return { ...args, url: processedUrl };
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
// Send a general tool call to Chrome extension with routing
|
||||
async callTool(name: string, args: any, sessionId?: string, userId?: string): Promise<any> {
|
||||
// Use current user context if not provided
|
||||
const effectiveUserId = userId || this.currentUserId;
|
||||
const effectiveSessionId = sessionId || this.currentSessionId;
|
||||
|
||||
this.logger.info(`🔧 [Chrome Tools] Calling tool: ${name} with routing context:`, {
|
||||
args,
|
||||
sessionId: effectiveSessionId,
|
||||
userId: effectiveUserId,
|
||||
usingCurrentContext: !userId && !sessionId,
|
||||
});
|
||||
|
||||
const message = {
|
||||
action: 'callTool',
|
||||
params: { name, arguments: args },
|
||||
};
|
||||
|
||||
this.logger.info(`🔧 [Chrome Tools] Sending routed message to extensions:`, message);
|
||||
|
||||
const result = await this.sendToExtensions(message, effectiveSessionId, effectiveUserId);
|
||||
|
||||
this.logger.info(`🔧 [Chrome Tools] Received result from extensions:`, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Get session statistics
|
||||
getSessionStats(): any {
|
||||
return this.sessionManager.getStats();
|
||||
}
|
||||
|
||||
// Get routing statistics
|
||||
getRoutingStats(): any {
|
||||
return this.connectionRouter.getRoutingStats();
|
||||
}
|
||||
|
||||
// Get connection by session ID
|
||||
getConnectionBySessionId(sessionId: string): ExtensionConnection | null {
|
||||
return this.sessionManager.getConnectionBySessionId(sessionId);
|
||||
}
|
||||
|
||||
// Get connection by user ID
|
||||
getConnectionByUserId(userId: string): ExtensionConnection | null {
|
||||
return this.sessionManager.getConnectionByUserId(userId);
|
||||
}
|
||||
|
||||
// Route message to specific connection type
|
||||
async callToolWithConnectionType(
|
||||
name: string,
|
||||
args: any,
|
||||
connectionType: 'newest' | 'oldest' | 'most_active',
|
||||
): Promise<any> {
|
||||
this.logger.info(
|
||||
`🔧 [Chrome Tools] Calling tool: ${name} with connection type: ${connectionType}`,
|
||||
);
|
||||
|
||||
const message = {
|
||||
action: 'callTool',
|
||||
params: { name, arguments: args },
|
||||
};
|
||||
|
||||
const routeResult = this.connectionRouter.routeToConnectionType(message, connectionType);
|
||||
const result = await this.sendToExtensions(message, routeResult.sessionId);
|
||||
|
||||
this.logger.info(`🔧 [Chrome Tools] Tool result from ${connectionType} connection:`, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Check if session can handle message
|
||||
canSessionHandleMessage(sessionId: string, messageType: string): boolean {
|
||||
return this.connectionRouter.canSessionHandleMessage(sessionId, messageType);
|
||||
}
|
||||
|
||||
// Get recommended session for user
|
||||
getRecommendedSessionForUser(userId: string): string | null {
|
||||
return this.connectionRouter.getRecommendedSessionForUser(userId);
|
||||
}
|
||||
|
||||
// Get LiveKit agent for user
|
||||
getLiveKitAgentForUser(userId: string): any {
|
||||
return this.liveKitAgentManager.getAgentForUser(userId);
|
||||
}
|
||||
|
||||
// Get LiveKit agent statistics
|
||||
getLiveKitAgentStats(): any {
|
||||
return this.liveKitAgentManager.getAgentStats();
|
||||
}
|
||||
|
||||
// Get all active LiveKit agents
|
||||
getAllActiveLiveKitAgents(): any[] {
|
||||
return this.liveKitAgentManager.getAllActiveAgents();
|
||||
}
|
||||
|
||||
// Cleanup resources
|
||||
destroy(): void {
|
||||
this.connectionRouter.cleanupRoutingRules();
|
||||
this.liveKitAgentManager.shutdownAllAgents();
|
||||
this.sessionManager.destroy();
|
||||
}
|
||||
|
||||
// Send a message to Chrome extensions with intelligent routing
|
||||
private async sendToExtensions(message: any, sessionId?: string, userId?: string): Promise<any> {
|
||||
const stats = this.sessionManager.getStats();
|
||||
this.logger.info(`📤 [Chrome Tools] Routing message to Chrome extensions:`, {
|
||||
action: message.action,
|
||||
connectionsCount: stats.activeConnections,
|
||||
sessionId,
|
||||
userId,
|
||||
fullMessage: message,
|
||||
});
|
||||
|
||||
if (stats.activeConnections === 0) {
|
||||
this.logger.error('🚫 [Chrome Tools] No Chrome extensions connected');
|
||||
throw new Error('No Chrome extensions connected');
|
||||
}
|
||||
|
||||
// Use connection router to find the best connection
|
||||
let routeResult: RouteResult;
|
||||
try {
|
||||
routeResult = this.connectionRouter.routeMessage(message, sessionId, userId);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to route message:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { connection: extensionConnection, routingReason } = routeResult;
|
||||
const connection = extensionConnection.connection;
|
||||
const readyState = (connection as any).readyState;
|
||||
|
||||
this.logger.info(
|
||||
`📤 [Chrome Tools] Routed to connection - Session: ${extensionConnection.sessionId}, User: ${extensionConnection.userId}, Reason: ${routingReason}, ReadyState: ${readyState}`,
|
||||
);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const messageId = Date.now().toString() + Math.random().toString(36).substring(2, 11);
|
||||
const messageWithId = { ...message, id: messageId };
|
||||
|
||||
// Store the request with session context
|
||||
this.sessionManager.storePendingRequest(
|
||||
messageId,
|
||||
resolve,
|
||||
reject,
|
||||
extensionConnection.sessionId,
|
||||
60000, // 60 second timeout
|
||||
);
|
||||
|
||||
try {
|
||||
// Check if connection is still open before sending
|
||||
if (readyState === 1) {
|
||||
// WebSocket.OPEN
|
||||
this.logger.info(
|
||||
`📤 [Chrome Tools] Sending message with ID ${messageId} to Chrome extension (Session: ${extensionConnection.sessionId}, Routing: ${routingReason}):`,
|
||||
messageWithId,
|
||||
);
|
||||
(connection as any).send(JSON.stringify(messageWithId));
|
||||
} else {
|
||||
this.sessionManager.rejectPendingRequest(
|
||||
messageId,
|
||||
new Error(`Chrome extension connection is not open (readyState: ${readyState})`),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.sessionManager.rejectPendingRequest(messageId, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async navigateToUrl(url: string): Promise<any> {
|
||||
this.logger.info(`Navigating to URL: ${url}`);
|
||||
|
||||
// Process natural language navigation requests
|
||||
const processedArgs = this.processNavigationRequest({ url });
|
||||
|
||||
return await this.sendToExtensions({
|
||||
action: 'navigate',
|
||||
params: processedArgs,
|
||||
});
|
||||
}
|
||||
|
||||
async getPageContent(selector?: string): Promise<any> {
|
||||
this.logger.info(`Getting page content${selector ? ` with selector: ${selector}` : ''}`);
|
||||
|
||||
return await this.sendToExtensions({
|
||||
action: 'getContent',
|
||||
params: { selector },
|
||||
});
|
||||
}
|
||||
|
||||
async clickElement(selector: string): Promise<any> {
|
||||
this.logger.info(`Clicking element: ${selector}`);
|
||||
|
||||
return await this.sendToExtensions({
|
||||
action: 'click',
|
||||
params: { selector },
|
||||
});
|
||||
}
|
||||
|
||||
async fillInput(selector: string, value: string): Promise<any> {
|
||||
this.logger.info(`Filling input ${selector} with value: ${value}`);
|
||||
|
||||
return await this.sendToExtensions({
|
||||
action: 'fillInput',
|
||||
params: { selector, value },
|
||||
});
|
||||
}
|
||||
|
||||
async takeScreenshot(fullPage: boolean = false): Promise<any> {
|
||||
this.logger.info(`Taking screenshot (fullPage: ${fullPage})`);
|
||||
|
||||
return await this.sendToExtensions({
|
||||
action: 'screenshot',
|
||||
params: { fullPage },
|
||||
});
|
||||
}
|
||||
|
||||
async executeScript(script: string): Promise<any> {
|
||||
this.logger.info('Executing script');
|
||||
|
||||
return await this.sendToExtensions({
|
||||
action: 'executeScript',
|
||||
params: { script },
|
||||
});
|
||||
}
|
||||
|
||||
async getCurrentTab(): Promise<any> {
|
||||
this.logger.info('Getting current tab info');
|
||||
|
||||
return await this.sendToExtensions({
|
||||
action: 'getCurrentTab',
|
||||
params: {},
|
||||
});
|
||||
}
|
||||
|
||||
async getAllTabs(): Promise<any> {
|
||||
this.logger.info('Getting all tabs');
|
||||
|
||||
return await this.sendToExtensions({
|
||||
action: 'getAllTabs',
|
||||
params: {},
|
||||
});
|
||||
}
|
||||
|
||||
async switchToTab(tabId: number): Promise<any> {
|
||||
this.logger.info(`Switching to tab: ${tabId}`);
|
||||
|
||||
return await this.sendToExtensions({
|
||||
action: 'switchTab',
|
||||
params: { tabId },
|
||||
});
|
||||
}
|
||||
|
||||
async createNewTab(url?: string): Promise<any> {
|
||||
this.logger.info(`Creating new tab${url ? ` with URL: ${url}` : ''}`);
|
||||
|
||||
return await this.sendToExtensions({
|
||||
action: 'createTab',
|
||||
params: { url },
|
||||
});
|
||||
}
|
||||
|
||||
async closeTab(tabId?: number): Promise<any> {
|
||||
this.logger.info(`Closing tab${tabId ? `: ${tabId}` : ' (current)'}`);
|
||||
|
||||
return await this.sendToExtensions({
|
||||
action: 'closeTab',
|
||||
params: { tabId },
|
||||
});
|
||||
}
|
||||
|
||||
// Browser automation tools matching the native server functionality
|
||||
|
||||
async getWindowsAndTabs(): Promise<any> {
|
||||
this.logger.info('Getting all windows and tabs');
|
||||
return await this.sendToExtensions({
|
||||
action: TOOL_NAMES.BROWSER.GET_WINDOWS_AND_TABS,
|
||||
params: {},
|
||||
});
|
||||
}
|
||||
|
||||
async searchTabsContent(query: string): Promise<any> {
|
||||
this.logger.info(`Searching tabs content for: ${query}`);
|
||||
return await this.sendToExtensions({
|
||||
action: TOOL_NAMES.BROWSER.SEARCH_TABS_CONTENT,
|
||||
params: { query },
|
||||
});
|
||||
}
|
||||
|
||||
async chromeNavigate(args: any): Promise<any> {
|
||||
this.logger.info(`Chrome navigate with args:`, args);
|
||||
|
||||
// Process natural language navigation requests
|
||||
const processedArgs = this.processNavigationRequest(args);
|
||||
|
||||
return await this.sendToExtensions({
|
||||
action: TOOL_NAMES.BROWSER.NAVIGATE,
|
||||
params: processedArgs,
|
||||
});
|
||||
}
|
||||
|
||||
async chromeScreenshot(args: any): Promise<any> {
|
||||
this.logger.info(`Chrome screenshot with args:`, args);
|
||||
return await this.sendToExtensions({
|
||||
action: TOOL_NAMES.BROWSER.SCREENSHOT,
|
||||
params: args,
|
||||
});
|
||||
}
|
||||
|
||||
async chromeCloseTabs(args: any): Promise<any> {
|
||||
this.logger.info(`Chrome close tabs with args:`, args);
|
||||
return await this.sendToExtensions({
|
||||
action: TOOL_NAMES.BROWSER.CLOSE_TABS,
|
||||
params: args,
|
||||
});
|
||||
}
|
||||
|
||||
async chromeGoBackOrForward(args: any): Promise<any> {
|
||||
this.logger.info(`Chrome go back/forward with args:`, args);
|
||||
return await this.sendToExtensions({
|
||||
action: TOOL_NAMES.BROWSER.GO_BACK_OR_FORWARD,
|
||||
params: args,
|
||||
});
|
||||
}
|
||||
|
||||
async chromeGetWebContent(args: any): Promise<any> {
|
||||
this.logger.info(`Chrome get web content with args:`, args);
|
||||
return await this.sendToExtensions({
|
||||
action: TOOL_NAMES.BROWSER.WEB_FETCHER,
|
||||
params: args,
|
||||
});
|
||||
}
|
||||
|
||||
async chromeClickElement(args: any): Promise<any> {
|
||||
this.logger.info(`Chrome click element with args:`, args);
|
||||
return await this.sendToExtensions({
|
||||
action: TOOL_NAMES.BROWSER.CLICK,
|
||||
params: args,
|
||||
});
|
||||
}
|
||||
|
||||
async chromeFillOrSelect(args: any): Promise<any> {
|
||||
this.logger.info(`Chrome fill or select with args:`, args);
|
||||
return await this.sendToExtensions({
|
||||
action: TOOL_NAMES.BROWSER.FILL,
|
||||
params: args,
|
||||
});
|
||||
}
|
||||
|
||||
async chromeGetInteractiveElements(args: any): Promise<any> {
|
||||
this.logger.info(`Chrome get interactive elements with args:`, args);
|
||||
return await this.sendToExtensions({
|
||||
action: TOOL_NAMES.BROWSER.GET_INTERACTIVE_ELEMENTS,
|
||||
params: args,
|
||||
});
|
||||
}
|
||||
|
||||
async chromeNetworkCaptureStart(args: any): Promise<any> {
|
||||
this.logger.info(`Chrome network capture start with args:`, args);
|
||||
return await this.sendToExtensions({
|
||||
action: TOOL_NAMES.BROWSER.NETWORK_CAPTURE_START,
|
||||
params: args,
|
||||
});
|
||||
}
|
||||
|
||||
async chromeNetworkCaptureStop(args: any): Promise<any> {
|
||||
this.logger.info(`Chrome network capture stop with args:`, args);
|
||||
return await this.sendToExtensions({
|
||||
action: TOOL_NAMES.BROWSER.NETWORK_CAPTURE_STOP,
|
||||
params: args,
|
||||
});
|
||||
}
|
||||
|
||||
async chromeNetworkRequest(args: any): Promise<any> {
|
||||
this.logger.info(`Chrome network request with args:`, args);
|
||||
return await this.sendToExtensions({
|
||||
action: TOOL_NAMES.BROWSER.NETWORK_REQUEST,
|
||||
params: args,
|
||||
});
|
||||
}
|
||||
|
||||
async chromeNetworkDebuggerStart(args: any): Promise<any> {
|
||||
this.logger.info(`Chrome network debugger start with args:`, args);
|
||||
return await this.sendToExtensions({
|
||||
action: TOOL_NAMES.BROWSER.NETWORK_DEBUGGER_START,
|
||||
params: args,
|
||||
});
|
||||
}
|
||||
|
||||
async chromeNetworkDebuggerStop(args: any): Promise<any> {
|
||||
this.logger.info(`Chrome network debugger stop with args:`, args);
|
||||
return await this.sendToExtensions({
|
||||
action: TOOL_NAMES.BROWSER.NETWORK_DEBUGGER_STOP,
|
||||
params: args,
|
||||
});
|
||||
}
|
||||
|
||||
async chromeKeyboard(args: any): Promise<any> {
|
||||
this.logger.info(`Chrome keyboard with args:`, args);
|
||||
return await this.sendToExtensions({
|
||||
action: TOOL_NAMES.BROWSER.KEYBOARD,
|
||||
params: args,
|
||||
});
|
||||
}
|
||||
|
||||
async chromeHistory(args: any): Promise<any> {
|
||||
this.logger.info(`Chrome history with args:`, args);
|
||||
return await this.sendToExtensions({
|
||||
action: TOOL_NAMES.BROWSER.HISTORY,
|
||||
params: args,
|
||||
});
|
||||
}
|
||||
|
||||
async chromeBookmarkSearch(args: any): Promise<any> {
|
||||
this.logger.info(`Chrome bookmark search with args:`, args);
|
||||
return await this.sendToExtensions({
|
||||
action: TOOL_NAMES.BROWSER.BOOKMARK_SEARCH,
|
||||
params: args,
|
||||
});
|
||||
}
|
||||
|
||||
async chromeBookmarkAdd(args: any): Promise<any> {
|
||||
this.logger.info(`Chrome bookmark add with args:`, args);
|
||||
return await this.sendToExtensions({
|
||||
action: TOOL_NAMES.BROWSER.BOOKMARK_ADD,
|
||||
params: args,
|
||||
});
|
||||
}
|
||||
|
||||
async chromeBookmarkDelete(args: any): Promise<any> {
|
||||
this.logger.info(`Chrome bookmark delete with args:`, args);
|
||||
return await this.sendToExtensions({
|
||||
action: TOOL_NAMES.BROWSER.BOOKMARK_DELETE,
|
||||
params: args,
|
||||
});
|
||||
}
|
||||
|
||||
async chromeInjectScript(args: any): Promise<any> {
|
||||
this.logger.info(`Chrome inject script with args:`, args);
|
||||
return await this.sendToExtensions({
|
||||
action: TOOL_NAMES.BROWSER.INJECT_SCRIPT,
|
||||
params: args,
|
||||
});
|
||||
}
|
||||
|
||||
async chromeSendCommandToInjectScript(args: any): Promise<any> {
|
||||
this.logger.info(`Chrome send command to inject script with args:`, args);
|
||||
return await this.sendToExtensions({
|
||||
action: TOOL_NAMES.BROWSER.SEND_COMMAND_TO_INJECT_SCRIPT,
|
||||
params: args,
|
||||
});
|
||||
}
|
||||
|
||||
async chromeConsole(args: any): Promise<any> {
|
||||
this.logger.info(`Chrome console with args:`, args);
|
||||
return await this.sendToExtensions({
|
||||
action: TOOL_NAMES.BROWSER.CONSOLE,
|
||||
params: args,
|
||||
});
|
||||
}
|
||||
|
||||
async chromeSearchGoogle(args: any): Promise<any> {
|
||||
this.logger.info(`Chrome search Google with args:`, args);
|
||||
return await this.sendToExtensions({
|
||||
action: TOOL_NAMES.BROWSER.SEARCH_GOOGLE,
|
||||
params: args,
|
||||
});
|
||||
}
|
||||
|
||||
async chromeSubmitForm(args: any): Promise<any> {
|
||||
this.logger.info(`Chrome submit form with args:`, args);
|
||||
return await this.sendToExtensions({
|
||||
action: TOOL_NAMES.BROWSER.SUBMIT_FORM,
|
||||
params: args,
|
||||
});
|
||||
}
|
||||
}
|
287
app/remote-server/src/server/connection-router.ts
Normal file
287
app/remote-server/src/server/connection-router.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
import { Logger } from 'pino';
|
||||
import { SessionManager, ExtensionConnection } from './session-manager.js';
|
||||
|
||||
export interface RoutingRule {
|
||||
sessionId?: string;
|
||||
userId?: string;
|
||||
priority: number;
|
||||
condition?: (connection: ExtensionConnection) => boolean;
|
||||
}
|
||||
|
||||
export interface RouteResult {
|
||||
connection: ExtensionConnection;
|
||||
sessionId: string;
|
||||
userId: string;
|
||||
routingReason: string;
|
||||
}
|
||||
|
||||
export class ConnectionRouter {
|
||||
private logger: Logger;
|
||||
private sessionManager: SessionManager;
|
||||
private routingRules: RoutingRule[] = [];
|
||||
|
||||
constructor(logger: Logger, sessionManager: SessionManager) {
|
||||
this.logger = logger;
|
||||
this.sessionManager = sessionManager;
|
||||
|
||||
// Set up default routing rules
|
||||
this.setupDefaultRoutingRules();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up default routing rules
|
||||
*/
|
||||
private setupDefaultRoutingRules(): void {
|
||||
// Rule 1: Route by exact session ID match (highest priority)
|
||||
this.addRoutingRule({
|
||||
priority: 100,
|
||||
condition: (connection: ExtensionConnection) => true, // Will be filtered by sessionId parameter
|
||||
});
|
||||
|
||||
// Rule 2: Route by user ID (medium priority)
|
||||
this.addRoutingRule({
|
||||
priority: 50,
|
||||
condition: (connection: ExtensionConnection) => connection.isActive,
|
||||
});
|
||||
|
||||
// Rule 3: Route to any active connection (lowest priority)
|
||||
this.addRoutingRule({
|
||||
priority: 10,
|
||||
condition: (connection: ExtensionConnection) => connection.isActive,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a custom routing rule
|
||||
*/
|
||||
addRoutingRule(rule: RoutingRule): void {
|
||||
this.routingRules.push(rule);
|
||||
// Sort by priority (highest first)
|
||||
this.routingRules.sort((a, b) => b.priority - a.priority);
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a message to the appropriate Chrome extension connection
|
||||
*/
|
||||
routeMessage(message: any, sessionId?: string, userId?: string): RouteResult {
|
||||
this.logger.info('Routing message:', {
|
||||
action: message.action,
|
||||
sessionId,
|
||||
userId,
|
||||
messageId: message.id,
|
||||
});
|
||||
|
||||
// Try to route by session ID first
|
||||
if (sessionId) {
|
||||
const connection = this.sessionManager.getConnectionBySessionId(sessionId);
|
||||
if (connection && connection.isActive) {
|
||||
return {
|
||||
connection,
|
||||
sessionId: connection.sessionId,
|
||||
userId: connection.userId,
|
||||
routingReason: 'exact_session_match',
|
||||
};
|
||||
} else {
|
||||
this.logger.warn(`No active connection found for session: ${sessionId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Try to route by user ID
|
||||
if (userId) {
|
||||
const connection = this.sessionManager.getConnectionByUserId(userId);
|
||||
if (connection && connection.isActive) {
|
||||
return {
|
||||
connection,
|
||||
sessionId: connection.sessionId,
|
||||
userId: connection.userId,
|
||||
routingReason: 'user_id_match',
|
||||
};
|
||||
} else {
|
||||
this.logger.warn(`No active connection found for user: ${userId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply routing rules to find best connection
|
||||
const activeConnections = this.sessionManager.getAllActiveConnections();
|
||||
|
||||
if (activeConnections.length === 0) {
|
||||
throw new Error('No active Chrome extension connections available');
|
||||
}
|
||||
|
||||
// Apply routing rules in priority order
|
||||
for (const rule of this.routingRules) {
|
||||
const candidates = activeConnections.filter((conn) => {
|
||||
// Apply session/user filters if specified in rule
|
||||
if (rule.sessionId && conn.sessionId !== rule.sessionId) return false;
|
||||
if (rule.userId && conn.userId !== rule.userId) return false;
|
||||
|
||||
// Apply custom condition
|
||||
if (rule.condition && !rule.condition(conn)) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (candidates.length > 0) {
|
||||
// Use the first candidate (could implement load balancing here)
|
||||
const selectedConnection = candidates[0];
|
||||
|
||||
return {
|
||||
connection: selectedConnection,
|
||||
sessionId: selectedConnection.sessionId,
|
||||
userId: selectedConnection.userId,
|
||||
routingReason: `rule_priority_${rule.priority}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: use first available active connection
|
||||
const fallbackConnection = activeConnections[0];
|
||||
return {
|
||||
connection: fallbackConnection,
|
||||
sessionId: fallbackConnection.sessionId,
|
||||
userId: fallbackConnection.userId,
|
||||
routingReason: 'fallback_first_available',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a message with load balancing
|
||||
*/
|
||||
routeMessageWithLoadBalancing(message: any, sessionId?: string, userId?: string): RouteResult {
|
||||
// For session-specific requests, use exact routing
|
||||
if (sessionId || userId) {
|
||||
return this.routeMessage(message, sessionId, userId);
|
||||
}
|
||||
|
||||
// For general requests, implement round-robin load balancing
|
||||
const activeConnections = this.sessionManager.getAllActiveConnections();
|
||||
|
||||
if (activeConnections.length === 0) {
|
||||
throw new Error('No active Chrome extension connections available');
|
||||
}
|
||||
|
||||
// Simple round-robin based on message timestamp
|
||||
const index = Date.now() % activeConnections.length;
|
||||
const selectedConnection = activeConnections[index];
|
||||
|
||||
return {
|
||||
connection: selectedConnection,
|
||||
sessionId: selectedConnection.sessionId,
|
||||
userId: selectedConnection.userId,
|
||||
routingReason: 'load_balanced_round_robin',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get routing statistics
|
||||
*/
|
||||
getRoutingStats(): any {
|
||||
const stats = this.sessionManager.getStats();
|
||||
return {
|
||||
...stats,
|
||||
routingRules: this.routingRules.length,
|
||||
routingRulesPriorities: this.routingRules.map((rule) => rule.priority),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Route message to specific connection type
|
||||
*/
|
||||
routeToConnectionType(
|
||||
message: any,
|
||||
connectionType: 'newest' | 'oldest' | 'most_active',
|
||||
): RouteResult {
|
||||
const activeConnections = this.sessionManager.getAllActiveConnections();
|
||||
|
||||
if (activeConnections.length === 0) {
|
||||
throw new Error('No active Chrome extension connections available');
|
||||
}
|
||||
|
||||
let selectedConnection: ExtensionConnection;
|
||||
|
||||
switch (connectionType) {
|
||||
case 'newest':
|
||||
selectedConnection = activeConnections.reduce((newest, current) =>
|
||||
current.connectedAt > newest.connectedAt ? current : newest,
|
||||
);
|
||||
break;
|
||||
|
||||
case 'oldest':
|
||||
selectedConnection = activeConnections.reduce((oldest, current) =>
|
||||
current.connectedAt < oldest.connectedAt ? current : oldest,
|
||||
);
|
||||
break;
|
||||
|
||||
case 'most_active':
|
||||
selectedConnection = activeConnections.reduce((mostActive, current) =>
|
||||
current.lastActivity > mostActive.lastActivity ? current : mostActive,
|
||||
);
|
||||
break;
|
||||
|
||||
default:
|
||||
selectedConnection = activeConnections[0];
|
||||
}
|
||||
|
||||
return {
|
||||
connection: selectedConnection,
|
||||
sessionId: selectedConnection.sessionId,
|
||||
userId: selectedConnection.userId,
|
||||
routingReason: `connection_type_${connectionType}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific session can handle a message type
|
||||
*/
|
||||
canSessionHandleMessage(sessionId: string, messageType: string): boolean {
|
||||
const connection = this.sessionManager.getConnectionBySessionId(sessionId);
|
||||
|
||||
if (!connection || !connection.isActive) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if connection has been active recently
|
||||
const timeSinceActivity = Date.now() - connection.lastActivity;
|
||||
const maxInactiveTime = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
if (timeSinceActivity > maxInactiveTime) {
|
||||
this.logger.warn(`Session ${sessionId} has been inactive for ${timeSinceActivity}ms`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Add message type specific checks here if needed
|
||||
// For now, assume all active connections can handle all message types
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recommended session for a user
|
||||
*/
|
||||
getRecommendedSessionForUser(userId: string): string | null {
|
||||
const connection = this.sessionManager.getConnectionByUserId(userId);
|
||||
return connection ? connection.sessionId : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup inactive routing rules
|
||||
*/
|
||||
cleanupRoutingRules(): void {
|
||||
// Remove rules that reference non-existent sessions
|
||||
const validSessionIds = new Set(
|
||||
this.sessionManager.getAllActiveConnections().map((conn) => conn.sessionId),
|
||||
);
|
||||
|
||||
const initialRuleCount = this.routingRules.length;
|
||||
this.routingRules = this.routingRules.filter((rule) => {
|
||||
if (rule.sessionId && !validSessionIds.has(rule.sessionId)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const removedRules = initialRuleCount - this.routingRules.length;
|
||||
if (removedRules > 0) {
|
||||
this.logger.info(`Cleaned up ${removedRules} invalid routing rules`);
|
||||
}
|
||||
}
|
||||
}
|
317
app/remote-server/src/server/livekit-agent-manager.ts
Normal file
317
app/remote-server/src/server/livekit-agent-manager.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
import { Logger } from 'pino';
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import { SessionManager, ExtensionConnection } from './session-manager.js';
|
||||
import path from 'path';
|
||||
|
||||
export interface LiveKitAgentInstance {
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
process: ChildProcess;
|
||||
roomName: string;
|
||||
startedAt: number;
|
||||
status: 'starting' | 'running' | 'stopping' | 'stopped' | 'error';
|
||||
pid?: number;
|
||||
}
|
||||
|
||||
export class LiveKitAgentManager {
|
||||
private logger: Logger;
|
||||
private sessionManager: SessionManager;
|
||||
private agentInstances: Map<string, LiveKitAgentInstance> = new Map(); // sessionId -> agent
|
||||
private userToAgent: Map<string, string> = new Map(); // userId -> sessionId
|
||||
private agentPath: string;
|
||||
private liveKitConfig: any;
|
||||
|
||||
constructor(logger: Logger, sessionManager: SessionManager, agentPath?: string) {
|
||||
this.logger = logger;
|
||||
this.sessionManager = sessionManager;
|
||||
this.agentPath = agentPath || path.join(process.cwd(), '../../agent-livekit');
|
||||
this.liveKitConfig = this.loadLiveKitConfig();
|
||||
}
|
||||
|
||||
private loadLiveKitConfig(): any {
|
||||
// Default LiveKit configuration
|
||||
return {
|
||||
livekit_url: process.env.LIVEKIT_URL || 'ws://localhost:7880',
|
||||
api_key: process.env.LIVEKIT_API_KEY || 'devkey',
|
||||
api_secret: process.env.LIVEKIT_API_SECRET || 'secret',
|
||||
room_prefix: 'mcp-chrome-user-',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a LiveKit agent for a Chrome extension connection
|
||||
*/
|
||||
async startAgentForConnection(connection: ExtensionConnection): Promise<LiveKitAgentInstance> {
|
||||
const { userId, sessionId } = connection;
|
||||
|
||||
// Check if agent already exists for this user
|
||||
const existingSessionId = this.userToAgent.get(userId);
|
||||
if (existingSessionId && this.agentInstances.has(existingSessionId)) {
|
||||
const existingAgent = this.agentInstances.get(existingSessionId)!;
|
||||
if (existingAgent.status === 'running' || existingAgent.status === 'starting') {
|
||||
this.logger.info(`Agent already running for user ${userId}, reusing existing agent`);
|
||||
return existingAgent;
|
||||
}
|
||||
}
|
||||
|
||||
// Create room name based on user ID
|
||||
const roomName = `${this.liveKitConfig.room_prefix}${userId}`;
|
||||
|
||||
this.logger.info(
|
||||
`Starting LiveKit agent for user ${userId}, session ${sessionId}, room ${roomName}`,
|
||||
);
|
||||
|
||||
// Create agent instance record
|
||||
const agentInstance: LiveKitAgentInstance = {
|
||||
userId,
|
||||
sessionId,
|
||||
process: null as any, // Will be set below
|
||||
roomName,
|
||||
startedAt: Date.now(),
|
||||
status: 'starting',
|
||||
};
|
||||
|
||||
try {
|
||||
// Spawn the full LiveKit agent process directly
|
||||
const agentProcess = spawn(
|
||||
'python',
|
||||
[
|
||||
'livekit_agent.py',
|
||||
'start',
|
||||
'--url',
|
||||
this.liveKitConfig.livekit_url,
|
||||
'--api-key',
|
||||
this.liveKitConfig.api_key,
|
||||
'--api-secret',
|
||||
this.liveKitConfig.api_secret,
|
||||
],
|
||||
{
|
||||
cwd: this.agentPath,
|
||||
env: {
|
||||
...process.env,
|
||||
LIVEKIT_URL: this.liveKitConfig.livekit_url,
|
||||
LIVEKIT_API_KEY: this.liveKitConfig.api_key,
|
||||
LIVEKIT_API_SECRET: this.liveKitConfig.api_secret,
|
||||
MCP_SERVER_URL: 'http://localhost:3001/mcp',
|
||||
CHROME_USER_ID: userId, // Pass the user ID as environment variable
|
||||
// Voice processing optimization
|
||||
LIVEKIT_ROOM_NAME: roomName,
|
||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY || '',
|
||||
DEEPGRAM_API_KEY: process.env.DEEPGRAM_API_KEY || '',
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
},
|
||||
);
|
||||
|
||||
agentInstance.process = agentProcess;
|
||||
agentInstance.pid = agentProcess.pid;
|
||||
|
||||
// Set up process event handlers
|
||||
agentProcess.stdout?.on('data', (data) => {
|
||||
const output = data.toString();
|
||||
this.logger.info(`[Agent ${userId}] ${output.trim()}`);
|
||||
|
||||
// Check for successful startup
|
||||
if (
|
||||
output.includes('Agent initialized successfully') ||
|
||||
output.includes('LiveKit agent started')
|
||||
) {
|
||||
agentInstance.status = 'running';
|
||||
this.logger.info(`LiveKit agent for user ${userId} is now running`);
|
||||
}
|
||||
});
|
||||
|
||||
agentProcess.stderr?.on('data', (data) => {
|
||||
const error = data.toString();
|
||||
this.logger.error(`[Agent ${userId}] ERROR: ${error.trim()}`);
|
||||
});
|
||||
|
||||
agentProcess.on('close', (code) => {
|
||||
this.logger.info(`LiveKit agent for user ${userId} exited with code ${code}`);
|
||||
agentInstance.status = code === 0 ? 'stopped' : 'error';
|
||||
|
||||
// Clean up mappings
|
||||
this.agentInstances.delete(sessionId);
|
||||
this.userToAgent.delete(userId);
|
||||
});
|
||||
|
||||
agentProcess.on('error', (error) => {
|
||||
this.logger.error(`Failed to start LiveKit agent for user ${userId}:`, error);
|
||||
agentInstance.status = 'error';
|
||||
});
|
||||
|
||||
// Store the agent instance
|
||||
this.agentInstances.set(sessionId, agentInstance);
|
||||
this.userToAgent.set(userId, sessionId);
|
||||
|
||||
this.logger.info(
|
||||
`LiveKit agent process started for user ${userId} with PID ${agentProcess.pid}`,
|
||||
);
|
||||
|
||||
return agentInstance;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error starting LiveKit agent for user ${userId}:`, error);
|
||||
agentInstance.status = 'error';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a LiveKit agent for a user
|
||||
*/
|
||||
async stopAgentForUser(userId: string): Promise<boolean> {
|
||||
const sessionId = this.userToAgent.get(userId);
|
||||
if (!sessionId) {
|
||||
this.logger.warn(`No agent found for user ${userId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.stopAgentForSession(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a LiveKit agent for a session
|
||||
*/
|
||||
async stopAgentForSession(sessionId: string): Promise<boolean> {
|
||||
const agentInstance = this.agentInstances.get(sessionId);
|
||||
if (!agentInstance) {
|
||||
this.logger.warn(`No agent found for session ${sessionId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
this.logger.info(
|
||||
`Stopping LiveKit agent for user ${agentInstance.userId}, session ${sessionId}`,
|
||||
);
|
||||
|
||||
agentInstance.status = 'stopping';
|
||||
|
||||
try {
|
||||
if (agentInstance.process && !agentInstance.process.killed) {
|
||||
// Try graceful shutdown first
|
||||
agentInstance.process.kill('SIGTERM');
|
||||
|
||||
// Force kill after 5 seconds if still running
|
||||
setTimeout(() => {
|
||||
if (agentInstance.process && !agentInstance.process.killed) {
|
||||
this.logger.warn(`Force killing LiveKit agent for user ${agentInstance.userId}`);
|
||||
agentInstance.process.kill('SIGKILL');
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error stopping LiveKit agent for user ${agentInstance.userId}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Chrome extension connection
|
||||
*/
|
||||
async onChromeExtensionConnected(connection: ExtensionConnection): Promise<void> {
|
||||
this.logger.info(
|
||||
`Chrome extension connected, starting LiveKit agent for user ${connection.userId}`,
|
||||
);
|
||||
|
||||
try {
|
||||
await this.startAgentForConnection(connection);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to start LiveKit agent for Chrome connection:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Chrome extension disconnection
|
||||
*/
|
||||
async onChromeExtensionDisconnected(connection: ExtensionConnection): Promise<void> {
|
||||
this.logger.info(
|
||||
`Chrome extension disconnected, stopping LiveKit agent for user ${connection.userId}`,
|
||||
);
|
||||
|
||||
try {
|
||||
await this.stopAgentForUser(connection.userId);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to stop LiveKit agent for Chrome disconnection:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agent instance for a user
|
||||
*/
|
||||
getAgentForUser(userId: string): LiveKitAgentInstance | null {
|
||||
const sessionId = this.userToAgent.get(userId);
|
||||
return sessionId ? this.agentInstances.get(sessionId) || null : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agent instance for a session
|
||||
*/
|
||||
getAgentForSession(sessionId: string): LiveKitAgentInstance | null {
|
||||
return this.agentInstances.get(sessionId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active agents
|
||||
*/
|
||||
getAllActiveAgents(): LiveKitAgentInstance[] {
|
||||
return Array.from(this.agentInstances.values()).filter(
|
||||
(agent) => agent.status === 'running' || agent.status === 'starting',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agent statistics
|
||||
*/
|
||||
getAgentStats(): any {
|
||||
const agents = Array.from(this.agentInstances.values());
|
||||
return {
|
||||
totalAgents: agents.length,
|
||||
runningAgents: agents.filter((a) => a.status === 'running').length,
|
||||
startingAgents: agents.filter((a) => a.status === 'starting').length,
|
||||
stoppedAgents: agents.filter((a) => a.status === 'stopped').length,
|
||||
errorAgents: agents.filter((a) => a.status === 'error').length,
|
||||
agentsByUser: Object.fromEntries(this.userToAgent.entries()),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup stopped agents
|
||||
*/
|
||||
cleanupStoppedAgents(): void {
|
||||
const stoppedAgents: string[] = [];
|
||||
|
||||
for (const [sessionId, agent] of this.agentInstances.entries()) {
|
||||
if (agent.status === 'stopped' || agent.status === 'error') {
|
||||
stoppedAgents.push(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const sessionId of stoppedAgents) {
|
||||
const agent = this.agentInstances.get(sessionId);
|
||||
if (agent) {
|
||||
this.agentInstances.delete(sessionId);
|
||||
this.userToAgent.delete(agent.userId);
|
||||
this.logger.info(`Cleaned up stopped agent for user ${agent.userId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown all agents
|
||||
*/
|
||||
async shutdownAllAgents(): Promise<void> {
|
||||
this.logger.info('Shutting down all LiveKit agents...');
|
||||
|
||||
const shutdownPromises = Array.from(this.agentInstances.keys()).map((sessionId) =>
|
||||
this.stopAgentForSession(sessionId),
|
||||
);
|
||||
|
||||
await Promise.all(shutdownPromises);
|
||||
|
||||
this.agentInstances.clear();
|
||||
this.userToAgent.clear();
|
||||
|
||||
this.logger.info('All LiveKit agents shut down');
|
||||
}
|
||||
}
|
256
app/remote-server/src/server/mcp-remote-server.ts
Normal file
256
app/remote-server/src/server/mcp-remote-server.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { Logger } from 'pino';
|
||||
import { ChromeTools } from './chrome-tools.js';
|
||||
import { TOOL_SCHEMAS, TOOL_NAMES } from 'chrome-mcp-shared';
|
||||
|
||||
export class MCPRemoteServer {
|
||||
private server: Server;
|
||||
private chromeTools: ChromeTools;
|
||||
private logger: Logger;
|
||||
|
||||
constructor(logger: Logger) {
|
||||
this.logger = logger;
|
||||
this.server = new Server(
|
||||
{
|
||||
name: 'mcp-chrome-remote-server',
|
||||
version: '1.0.0',
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
this.chromeTools = new ChromeTools(logger);
|
||||
this.setupHandlers();
|
||||
}
|
||||
|
||||
// Register Chrome extension connection with session management
|
||||
registerChromeExtension(
|
||||
connection: any,
|
||||
userId?: string,
|
||||
metadata?: any,
|
||||
): { userId: string; sessionId: string; connectionId: string } {
|
||||
return this.chromeTools.registerExtension(connection, userId, metadata);
|
||||
}
|
||||
|
||||
// Unregister Chrome extension connection
|
||||
unregisterChromeExtension(connection: any): boolean {
|
||||
return this.chromeTools.unregisterExtension(connection);
|
||||
}
|
||||
|
||||
// Get session statistics
|
||||
getSessionStats(): any {
|
||||
return this.chromeTools.getSessionStats();
|
||||
}
|
||||
|
||||
// Handle responses from Chrome extension
|
||||
handleChromeResponse(data: any) {
|
||||
this.chromeTools.handleResponse(data);
|
||||
}
|
||||
|
||||
// Update Chrome extension user ID
|
||||
updateChromeExtensionUserId(connection: any, newUserId: string): any {
|
||||
return this.chromeTools.updateExtensionUserId(connection, newUserId);
|
||||
}
|
||||
|
||||
// Set user context for routing
|
||||
setUserContext(userId: string, sessionId?: string) {
|
||||
this.chromeTools.setUserContext(userId, sessionId);
|
||||
}
|
||||
|
||||
// Connect a streaming transport to the MCP server
|
||||
async connectTransport(transport: SSEServerTransport | StreamableHTTPServerTransport) {
|
||||
try {
|
||||
await this.server.connect(transport);
|
||||
this.logger.info('MCP server connected to streaming transport');
|
||||
} catch (error) {
|
||||
this.logger.error('Error connecting MCP server to transport:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private setupHandlers() {
|
||||
// List available tools
|
||||
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
return { tools: TOOL_SCHEMAS };
|
||||
});
|
||||
|
||||
// Handle tool calls
|
||||
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { name, arguments: args } = request.params;
|
||||
|
||||
this.logger.info('🔧 [MCP Server] Handling tool call:', {
|
||||
toolName: name,
|
||||
hasArgs: !!args,
|
||||
args,
|
||||
});
|
||||
|
||||
try {
|
||||
let result;
|
||||
|
||||
switch (name) {
|
||||
// Legacy tool names for backward compatibility
|
||||
case 'navigate_to_url':
|
||||
result = await this.chromeTools.navigateToUrl((args as any)?.url);
|
||||
break;
|
||||
case 'get_page_content':
|
||||
result = await this.chromeTools.getPageContent((args as any)?.selector);
|
||||
break;
|
||||
case 'click_element':
|
||||
result = await this.chromeTools.clickElement((args as any)?.selector);
|
||||
break;
|
||||
case 'fill_input':
|
||||
result = await this.chromeTools.fillInput(
|
||||
(args as any)?.selector,
|
||||
(args as any)?.value,
|
||||
);
|
||||
break;
|
||||
case 'take_screenshot':
|
||||
result = await this.chromeTools.takeScreenshot((args as any)?.fullPage);
|
||||
break;
|
||||
|
||||
// Browser automation tools matching native server
|
||||
case TOOL_NAMES.BROWSER.GET_WINDOWS_AND_TABS:
|
||||
result = await this.chromeTools.getWindowsAndTabs();
|
||||
break;
|
||||
case TOOL_NAMES.BROWSER.SEARCH_TABS_CONTENT:
|
||||
result = await this.chromeTools.searchTabsContent((args as any)?.query);
|
||||
break;
|
||||
case TOOL_NAMES.BROWSER.NAVIGATE:
|
||||
result = await this.chromeTools.chromeNavigate(args);
|
||||
break;
|
||||
case TOOL_NAMES.BROWSER.SCREENSHOT:
|
||||
result = await this.chromeTools.chromeScreenshot(args);
|
||||
break;
|
||||
case TOOL_NAMES.BROWSER.CLOSE_TABS:
|
||||
result = await this.chromeTools.chromeCloseTabs(args);
|
||||
break;
|
||||
case TOOL_NAMES.BROWSER.GO_BACK_OR_FORWARD:
|
||||
result = await this.chromeTools.chromeGoBackOrForward(args);
|
||||
break;
|
||||
case TOOL_NAMES.BROWSER.WEB_FETCHER:
|
||||
result = await this.chromeTools.chromeGetWebContent(args);
|
||||
break;
|
||||
case TOOL_NAMES.BROWSER.CLICK:
|
||||
result = await this.chromeTools.chromeClickElement(args);
|
||||
break;
|
||||
case TOOL_NAMES.BROWSER.FILL:
|
||||
result = await this.chromeTools.chromeFillOrSelect(args);
|
||||
break;
|
||||
case TOOL_NAMES.BROWSER.GET_INTERACTIVE_ELEMENTS:
|
||||
result = await this.chromeTools.chromeGetInteractiveElements(args);
|
||||
break;
|
||||
case TOOL_NAMES.BROWSER.NETWORK_CAPTURE_START:
|
||||
result = await this.chromeTools.chromeNetworkCaptureStart(args);
|
||||
break;
|
||||
case TOOL_NAMES.BROWSER.NETWORK_CAPTURE_STOP:
|
||||
result = await this.chromeTools.chromeNetworkCaptureStop(args);
|
||||
break;
|
||||
case TOOL_NAMES.BROWSER.NETWORK_REQUEST:
|
||||
result = await this.chromeTools.chromeNetworkRequest(args);
|
||||
break;
|
||||
case TOOL_NAMES.BROWSER.NETWORK_DEBUGGER_START:
|
||||
result = await this.chromeTools.chromeNetworkDebuggerStart(args);
|
||||
break;
|
||||
case TOOL_NAMES.BROWSER.NETWORK_DEBUGGER_STOP:
|
||||
result = await this.chromeTools.chromeNetworkDebuggerStop(args);
|
||||
break;
|
||||
case TOOL_NAMES.BROWSER.KEYBOARD:
|
||||
result = await this.chromeTools.chromeKeyboard(args);
|
||||
break;
|
||||
case TOOL_NAMES.BROWSER.HISTORY:
|
||||
result = await this.chromeTools.chromeHistory(args);
|
||||
break;
|
||||
case TOOL_NAMES.BROWSER.BOOKMARK_SEARCH:
|
||||
result = await this.chromeTools.chromeBookmarkSearch(args);
|
||||
break;
|
||||
case TOOL_NAMES.BROWSER.BOOKMARK_ADD:
|
||||
result = await this.chromeTools.chromeBookmarkAdd(args);
|
||||
break;
|
||||
case TOOL_NAMES.BROWSER.BOOKMARK_DELETE:
|
||||
result = await this.chromeTools.chromeBookmarkDelete(args);
|
||||
break;
|
||||
case TOOL_NAMES.BROWSER.INJECT_SCRIPT:
|
||||
result = await this.chromeTools.chromeInjectScript(args);
|
||||
break;
|
||||
case TOOL_NAMES.BROWSER.SEND_COMMAND_TO_INJECT_SCRIPT:
|
||||
result = await this.chromeTools.chromeSendCommandToInjectScript(args);
|
||||
break;
|
||||
case TOOL_NAMES.BROWSER.CONSOLE:
|
||||
result = await this.chromeTools.chromeConsole(args);
|
||||
break;
|
||||
case TOOL_NAMES.BROWSER.SEARCH_GOOGLE:
|
||||
result = await this.chromeTools.chromeSearchGoogle(args);
|
||||
break;
|
||||
case TOOL_NAMES.BROWSER.SUBMIT_FORM:
|
||||
result = await this.chromeTools.chromeSubmitForm(args);
|
||||
break;
|
||||
default:
|
||||
// Use the general tool call method for any tools not explicitly mapped
|
||||
result = await this.chromeTools.callTool(name, args);
|
||||
}
|
||||
|
||||
this.logger.info('🔧 [MCP Server] Tool call completed:', {
|
||||
toolName: name,
|
||||
hasResult: !!result,
|
||||
result,
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(result, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`🔧 [MCP Server] Error executing tool ${name}:`, error);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async handleMessage(message: any): Promise<any> {
|
||||
// This method will handle incoming WebSocket messages
|
||||
// and route them to the appropriate MCP server handlers
|
||||
try {
|
||||
// For now, we'll implement a simple message routing
|
||||
// In a full implementation, you'd want to properly handle the MCP protocol
|
||||
|
||||
if (message.method === 'tools/list') {
|
||||
const response = await this.server.request(
|
||||
{ method: 'tools/list', params: {} },
|
||||
ListToolsRequestSchema,
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
if (message.method === 'tools/call') {
|
||||
const response = await this.server.request(
|
||||
{ method: 'tools/call', params: message.params },
|
||||
CallToolRequestSchema,
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
return { error: 'Unknown method' };
|
||||
} catch (error) {
|
||||
this.logger.error('Error handling message:', error);
|
||||
return { error: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
}
|
||||
}
|
476
app/remote-server/src/server/session-manager.ts
Normal file
476
app/remote-server/src/server/session-manager.ts
Normal file
@@ -0,0 +1,476 @@
|
||||
import { Logger } from 'pino';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
export interface UserSession {
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
connectionId: string;
|
||||
createdAt: number;
|
||||
lastActivity: number;
|
||||
metadata: {
|
||||
userAgent?: string;
|
||||
ipAddress?: string;
|
||||
extensionVersion?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ExtensionConnection {
|
||||
connection: any;
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
connectionId: string;
|
||||
connectedAt: number;
|
||||
lastActivity: number;
|
||||
isActive: boolean;
|
||||
metadata: any;
|
||||
}
|
||||
|
||||
export interface PendingRequest {
|
||||
resolve: Function;
|
||||
reject: Function;
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
createdAt: number;
|
||||
timeout: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
export class SessionManager {
|
||||
private logger: Logger;
|
||||
private userSessions: Map<string, UserSession> = new Map();
|
||||
private extensionConnections: Map<string, ExtensionConnection> = new Map();
|
||||
private sessionToConnection: Map<string, string> = new Map();
|
||||
private userToSessions: Map<string, Set<string>> = new Map();
|
||||
private pendingRequests: Map<string, PendingRequest> = new Map();
|
||||
private cleanupInterval: NodeJS.Timeout;
|
||||
|
||||
constructor(logger: Logger) {
|
||||
this.logger = logger;
|
||||
|
||||
// Start cleanup interval for stale sessions and connections
|
||||
this.cleanupInterval = setInterval(() => {
|
||||
this.cleanupStaleConnections();
|
||||
this.cleanupExpiredRequests();
|
||||
}, 30000); // Check every 30 seconds
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique user ID
|
||||
*/
|
||||
generateUserId(): string {
|
||||
return `user_${randomUUID()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique session ID
|
||||
*/
|
||||
generateSessionId(): string {
|
||||
return `session_${randomUUID()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique connection ID
|
||||
*/
|
||||
generateConnectionId(): string {
|
||||
return `conn_${randomUUID()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new Chrome extension connection
|
||||
*/
|
||||
registerExtensionConnection(
|
||||
connection: any,
|
||||
userId?: string,
|
||||
metadata: any = {},
|
||||
): { userId: string; sessionId: string; connectionId: string } {
|
||||
const actualUserId = userId || this.generateUserId();
|
||||
const sessionId = this.generateSessionId();
|
||||
const connectionId = this.generateConnectionId();
|
||||
|
||||
// Create user session
|
||||
const userSession: UserSession = {
|
||||
userId: actualUserId,
|
||||
sessionId,
|
||||
connectionId,
|
||||
createdAt: Date.now(),
|
||||
lastActivity: Date.now(),
|
||||
metadata: {
|
||||
userAgent: metadata.userAgent,
|
||||
ipAddress: metadata.ipAddress,
|
||||
extensionVersion: metadata.extensionVersion,
|
||||
...metadata,
|
||||
},
|
||||
};
|
||||
|
||||
// Create extension connection
|
||||
const extensionConnection: ExtensionConnection = {
|
||||
connection,
|
||||
userId: actualUserId,
|
||||
sessionId,
|
||||
connectionId,
|
||||
connectedAt: Date.now(),
|
||||
lastActivity: Date.now(),
|
||||
isActive: true,
|
||||
metadata,
|
||||
};
|
||||
|
||||
// Store mappings
|
||||
this.userSessions.set(sessionId, userSession);
|
||||
this.extensionConnections.set(connectionId, extensionConnection);
|
||||
this.sessionToConnection.set(sessionId, connectionId);
|
||||
|
||||
// Track user sessions
|
||||
if (!this.userToSessions.has(actualUserId)) {
|
||||
this.userToSessions.set(actualUserId, new Set());
|
||||
}
|
||||
this.userToSessions.get(actualUserId)!.add(sessionId);
|
||||
|
||||
this.logger.info(
|
||||
`Extension registered - User: ${actualUserId}, Session: ${sessionId}, Connection: ${connectionId}`,
|
||||
);
|
||||
this.logConnectionStats();
|
||||
|
||||
return { userId: actualUserId, sessionId, connectionId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a Chrome extension connection
|
||||
*/
|
||||
unregisterExtensionConnection(connection: any): boolean {
|
||||
// Find connection by reference
|
||||
let connectionToRemove: ExtensionConnection | null = null;
|
||||
let connectionId: string | null = null;
|
||||
|
||||
for (const [id, extConnection] of this.extensionConnections.entries()) {
|
||||
if (extConnection.connection === connection) {
|
||||
connectionToRemove = extConnection;
|
||||
connectionId = id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!connectionToRemove || !connectionId) {
|
||||
this.logger.warn('Attempted to unregister unknown connection');
|
||||
return false;
|
||||
}
|
||||
|
||||
const { userId, sessionId } = connectionToRemove;
|
||||
|
||||
// Remove from all mappings
|
||||
this.extensionConnections.delete(connectionId);
|
||||
this.sessionToConnection.delete(sessionId);
|
||||
this.userSessions.delete(sessionId);
|
||||
|
||||
// Update user sessions
|
||||
const userSessions = this.userToSessions.get(userId);
|
||||
if (userSessions) {
|
||||
userSessions.delete(sessionId);
|
||||
if (userSessions.size === 0) {
|
||||
this.userToSessions.delete(userId);
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel any pending requests for this session
|
||||
this.cancelPendingRequestsForSession(sessionId);
|
||||
|
||||
this.logger.info(
|
||||
`Extension unregistered - User: ${userId}, Session: ${sessionId}, Connection: ${connectionId}`,
|
||||
);
|
||||
this.logConnectionStats();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get extension connection by session ID
|
||||
*/
|
||||
getConnectionBySessionId(sessionId: string): ExtensionConnection | null {
|
||||
const connectionId = this.sessionToConnection.get(sessionId);
|
||||
if (!connectionId) {
|
||||
return null;
|
||||
}
|
||||
return this.extensionConnections.get(connectionId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get extension connection by user ID (returns first active connection)
|
||||
*/
|
||||
getConnectionByUserId(userId: string): ExtensionConnection | null {
|
||||
const userSessions = this.userToSessions.get(userId);
|
||||
if (!userSessions || userSessions.size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find first active connection
|
||||
for (const sessionId of userSessions) {
|
||||
const connection = this.getConnectionBySessionId(sessionId);
|
||||
if (connection && connection.isActive) {
|
||||
return connection;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active connections
|
||||
*/
|
||||
getAllActiveConnections(): ExtensionConnection[] {
|
||||
return Array.from(this.extensionConnections.values()).filter((conn) => conn.isActive);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update last activity for a session
|
||||
*/
|
||||
updateSessionActivity(sessionId: string): void {
|
||||
const session = this.userSessions.get(sessionId);
|
||||
if (session) {
|
||||
session.lastActivity = Date.now();
|
||||
}
|
||||
|
||||
const connectionId = this.sessionToConnection.get(sessionId);
|
||||
if (connectionId) {
|
||||
const connection = this.extensionConnections.get(connectionId);
|
||||
if (connection) {
|
||||
connection.lastActivity = Date.now();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user ID for an existing extension connection
|
||||
*/
|
||||
updateExtensionUserId(connection: any, newUserId: string): any {
|
||||
// Find the extension connection
|
||||
let targetConnection: ExtensionConnection | null = null;
|
||||
let targetConnectionId: string | null = null;
|
||||
|
||||
for (const [connectionId, extConnection] of this.extensionConnections.entries()) {
|
||||
if (extConnection.connection === connection) {
|
||||
targetConnection = extConnection;
|
||||
targetConnectionId = connectionId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetConnection || !targetConnectionId) {
|
||||
this.logger.warn('Extension connection not found for user ID update');
|
||||
return null;
|
||||
}
|
||||
|
||||
const oldUserId = targetConnection.userId;
|
||||
const sessionId = targetConnection.sessionId;
|
||||
|
||||
// Update the extension connection
|
||||
targetConnection.userId = newUserId;
|
||||
targetConnection.lastActivity = Date.now();
|
||||
|
||||
// Update the user session
|
||||
const userSession = this.userSessions.get(sessionId);
|
||||
if (userSession) {
|
||||
userSession.userId = newUserId;
|
||||
userSession.lastActivity = Date.now();
|
||||
}
|
||||
|
||||
// Update user to sessions mapping
|
||||
const oldUserSessions = this.userToSessions.get(oldUserId);
|
||||
if (oldUserSessions) {
|
||||
oldUserSessions.delete(sessionId);
|
||||
if (oldUserSessions.size === 0) {
|
||||
this.userToSessions.delete(oldUserId);
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.userToSessions.has(newUserId)) {
|
||||
this.userToSessions.set(newUserId, new Set());
|
||||
}
|
||||
this.userToSessions.get(newUserId)!.add(sessionId);
|
||||
|
||||
this.logger.info(`Updated extension user ID from ${oldUserId} to ${newUserId}`);
|
||||
|
||||
return {
|
||||
userId: newUserId,
|
||||
oldUserId: oldUserId,
|
||||
sessionId: sessionId,
|
||||
connectionId: targetConnectionId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a pending request with session context
|
||||
*/
|
||||
storePendingRequest(
|
||||
requestId: string,
|
||||
resolve: Function,
|
||||
reject: Function,
|
||||
sessionId: string,
|
||||
timeoutMs: number = 60000,
|
||||
): void {
|
||||
const session = this.userSessions.get(sessionId);
|
||||
if (!session) {
|
||||
reject(new Error(`Session ${sessionId} not found`));
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
this.pendingRequests.delete(requestId);
|
||||
reject(new Error(`Request ${requestId} timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
const pendingRequest: PendingRequest = {
|
||||
resolve,
|
||||
reject,
|
||||
userId: session.userId,
|
||||
sessionId,
|
||||
createdAt: Date.now(),
|
||||
timeout,
|
||||
};
|
||||
|
||||
this.pendingRequests.set(requestId, pendingRequest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a pending request
|
||||
*/
|
||||
resolvePendingRequest(requestId: string, result: any): boolean {
|
||||
const request = this.pendingRequests.get(requestId);
|
||||
if (!request) {
|
||||
return false;
|
||||
}
|
||||
|
||||
clearTimeout(request.timeout);
|
||||
this.pendingRequests.delete(requestId);
|
||||
request.resolve(result);
|
||||
|
||||
// Update session activity
|
||||
this.updateSessionActivity(request.sessionId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a pending request
|
||||
*/
|
||||
rejectPendingRequest(requestId: string, error: any): boolean {
|
||||
const request = this.pendingRequests.get(requestId);
|
||||
if (!request) {
|
||||
return false;
|
||||
}
|
||||
|
||||
clearTimeout(request.timeout);
|
||||
this.pendingRequests.delete(requestId);
|
||||
request.reject(error);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel all pending requests for a session
|
||||
*/
|
||||
private cancelPendingRequestsForSession(sessionId: string): void {
|
||||
const requestsToCancel: string[] = [];
|
||||
|
||||
for (const [requestId, request] of this.pendingRequests.entries()) {
|
||||
if (request.sessionId === sessionId) {
|
||||
requestsToCancel.push(requestId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const requestId of requestsToCancel) {
|
||||
this.rejectPendingRequest(requestId, new Error(`Session ${sessionId} disconnected`));
|
||||
}
|
||||
|
||||
this.logger.info(
|
||||
`Cancelled ${requestsToCancel.length} pending requests for session ${sessionId}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up stale connections and sessions
|
||||
*/
|
||||
private cleanupStaleConnections(): void {
|
||||
const now = Date.now();
|
||||
const staleThreshold = 5 * 60 * 1000; // 5 minutes
|
||||
const connectionsToRemove: string[] = [];
|
||||
|
||||
for (const [connectionId, connection] of this.extensionConnections.entries()) {
|
||||
if (now - connection.lastActivity > staleThreshold) {
|
||||
connectionsToRemove.push(connectionId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const connectionId of connectionsToRemove) {
|
||||
const connection = this.extensionConnections.get(connectionId);
|
||||
if (connection) {
|
||||
this.logger.info(`Cleaning up stale connection: ${connectionId}`);
|
||||
this.unregisterExtensionConnection(connection.connection);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired requests
|
||||
*/
|
||||
private cleanupExpiredRequests(): void {
|
||||
const now = Date.now();
|
||||
const expiredThreshold = 2 * 60 * 1000; // 2 minutes
|
||||
const requestsToRemove: string[] = [];
|
||||
|
||||
for (const [requestId, request] of this.pendingRequests.entries()) {
|
||||
if (now - request.createdAt > expiredThreshold) {
|
||||
requestsToRemove.push(requestId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const requestId of requestsToRemove) {
|
||||
this.rejectPendingRequest(requestId, new Error('Request expired'));
|
||||
}
|
||||
|
||||
if (requestsToRemove.length > 0) {
|
||||
this.logger.info(`Cleaned up ${requestsToRemove.length} expired requests`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log connection statistics
|
||||
*/
|
||||
private logConnectionStats(): void {
|
||||
this.logger.info(
|
||||
`Connection Stats - Users: ${this.userToSessions.size}, Sessions: ${this.userSessions.size}, Connections: ${this.extensionConnections.size}, Pending Requests: ${this.pendingRequests.size}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session statistics
|
||||
*/
|
||||
getStats(): any {
|
||||
return {
|
||||
totalUsers: this.userToSessions.size,
|
||||
totalSessions: this.userSessions.size,
|
||||
totalConnections: this.extensionConnections.size,
|
||||
activeConnections: this.getAllActiveConnections().length,
|
||||
pendingRequests: this.pendingRequests.size,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup resources
|
||||
*/
|
||||
destroy(): void {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
}
|
||||
|
||||
// Cancel all pending requests
|
||||
for (const [requestId, request] of this.pendingRequests.entries()) {
|
||||
clearTimeout(request.timeout);
|
||||
request.reject(new Error('Session manager destroyed'));
|
||||
}
|
||||
|
||||
this.pendingRequests.clear();
|
||||
this.extensionConnections.clear();
|
||||
this.userSessions.clear();
|
||||
this.sessionToConnection.clear();
|
||||
this.userToSessions.clear();
|
||||
}
|
||||
}
|
304
app/remote-server/src/server/user-auth.ts
Normal file
304
app/remote-server/src/server/user-auth.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import { Logger } from 'pino';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
export interface UserToken {
|
||||
userId: string;
|
||||
tokenId: string;
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
metadata: {
|
||||
userAgent?: string;
|
||||
ipAddress?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AuthResult {
|
||||
success: boolean;
|
||||
userId?: string;
|
||||
sessionId?: string;
|
||||
token?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class UserAuthManager {
|
||||
private logger: Logger;
|
||||
private userTokens: Map<string, UserToken> = new Map(); // tokenId -> UserToken
|
||||
private userSessions: Map<string, Set<string>> = new Map(); // userId -> Set<tokenId>
|
||||
private tokenCleanupInterval: NodeJS.Timeout;
|
||||
|
||||
constructor(logger: Logger) {
|
||||
this.logger = logger;
|
||||
|
||||
// Start token cleanup interval
|
||||
this.tokenCleanupInterval = setInterval(() => {
|
||||
this.cleanupExpiredTokens();
|
||||
}, 60000); // Check every minute
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new user authentication token
|
||||
*/
|
||||
generateUserToken(metadata: any = {}): AuthResult {
|
||||
const userId = `user_${randomUUID()}`;
|
||||
const tokenId = `token_${randomUUID()}`;
|
||||
const now = Date.now();
|
||||
const expiresAt = now + (24 * 60 * 60 * 1000); // 24 hours
|
||||
|
||||
const userToken: UserToken = {
|
||||
userId,
|
||||
tokenId,
|
||||
createdAt: now,
|
||||
expiresAt,
|
||||
metadata: {
|
||||
userAgent: metadata.userAgent,
|
||||
ipAddress: metadata.ipAddress,
|
||||
...metadata
|
||||
}
|
||||
};
|
||||
|
||||
// Store token
|
||||
this.userTokens.set(tokenId, userToken);
|
||||
|
||||
// Track user sessions
|
||||
if (!this.userSessions.has(userId)) {
|
||||
this.userSessions.set(userId, new Set());
|
||||
}
|
||||
this.userSessions.get(userId)!.add(tokenId);
|
||||
|
||||
this.logger.info(`Generated user token - User: ${userId}, Token: ${tokenId}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
userId,
|
||||
token: tokenId,
|
||||
sessionId: `session_${userId}_${Date.now()}`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a user token
|
||||
*/
|
||||
validateToken(tokenId: string): AuthResult {
|
||||
const userToken = this.userTokens.get(tokenId);
|
||||
|
||||
if (!userToken) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid token'
|
||||
};
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
if (Date.now() > userToken.expiresAt) {
|
||||
this.revokeToken(tokenId);
|
||||
return {
|
||||
success: false,
|
||||
error: 'Token expired'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
userId: userToken.userId,
|
||||
sessionId: `session_${userToken.userId}_${userToken.createdAt}`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh a user token (extend expiration)
|
||||
*/
|
||||
refreshToken(tokenId: string): AuthResult {
|
||||
const userToken = this.userTokens.get(tokenId);
|
||||
|
||||
if (!userToken) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid token'
|
||||
};
|
||||
}
|
||||
|
||||
// Extend expiration by 24 hours
|
||||
userToken.expiresAt = Date.now() + (24 * 60 * 60 * 1000);
|
||||
|
||||
this.logger.info(`Refreshed token: ${tokenId} for user: ${userToken.userId}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
userId: userToken.userId,
|
||||
token: tokenId,
|
||||
sessionId: `session_${userToken.userId}_${userToken.createdAt}`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a user token
|
||||
*/
|
||||
revokeToken(tokenId: string): boolean {
|
||||
const userToken = this.userTokens.get(tokenId);
|
||||
|
||||
if (!userToken) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Remove from user sessions
|
||||
const userSessions = this.userSessions.get(userToken.userId);
|
||||
if (userSessions) {
|
||||
userSessions.delete(tokenId);
|
||||
if (userSessions.size === 0) {
|
||||
this.userSessions.delete(userToken.userId);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove token
|
||||
this.userTokens.delete(tokenId);
|
||||
|
||||
this.logger.info(`Revoked token: ${tokenId} for user: ${userToken.userId}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke all tokens for a user
|
||||
*/
|
||||
revokeUserTokens(userId: string): number {
|
||||
const userSessions = this.userSessions.get(userId);
|
||||
|
||||
if (!userSessions) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let revokedCount = 0;
|
||||
for (const tokenId of userSessions) {
|
||||
if (this.userTokens.delete(tokenId)) {
|
||||
revokedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
this.userSessions.delete(userId);
|
||||
|
||||
this.logger.info(`Revoked ${revokedCount} tokens for user: ${userId}`);
|
||||
return revokedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user information by token
|
||||
*/
|
||||
getUserInfo(tokenId: string): UserToken | null {
|
||||
return this.userTokens.get(tokenId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active tokens for a user
|
||||
*/
|
||||
getUserTokens(userId: string): UserToken[] {
|
||||
const userSessions = this.userSessions.get(userId);
|
||||
|
||||
if (!userSessions) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const tokens: UserToken[] = [];
|
||||
for (const tokenId of userSessions) {
|
||||
const token = this.userTokens.get(tokenId);
|
||||
if (token) {
|
||||
tokens.push(token);
|
||||
}
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract user ID from session ID
|
||||
*/
|
||||
extractUserIdFromSession(sessionId: string): string | null {
|
||||
// Session format: session_{userId}_{timestamp}
|
||||
const match = sessionId.match(/^session_(.+?)_\d+$/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create anonymous user session (no token required)
|
||||
*/
|
||||
createAnonymousSession(metadata: any = {}): AuthResult {
|
||||
const userId = `anon_${randomUUID()}`;
|
||||
const sessionId = `session_${userId}_${Date.now()}`;
|
||||
|
||||
this.logger.info(`Created anonymous session - User: ${userId}, Session: ${sessionId}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
userId,
|
||||
sessionId
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired tokens
|
||||
*/
|
||||
private cleanupExpiredTokens(): void {
|
||||
const now = Date.now();
|
||||
const tokensToRemove: string[] = [];
|
||||
|
||||
for (const [tokenId, userToken] of this.userTokens.entries()) {
|
||||
if (now > userToken.expiresAt) {
|
||||
tokensToRemove.push(tokenId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const tokenId of tokensToRemove) {
|
||||
this.revokeToken(tokenId);
|
||||
}
|
||||
|
||||
if (tokensToRemove.length > 0) {
|
||||
this.logger.info(`Cleaned up ${tokensToRemove.length} expired tokens`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get authentication statistics
|
||||
*/
|
||||
getAuthStats(): any {
|
||||
return {
|
||||
totalTokens: this.userTokens.size,
|
||||
totalUsers: this.userSessions.size,
|
||||
activeTokens: Array.from(this.userTokens.values()).filter(token => Date.now() < token.expiresAt).length
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate request from headers
|
||||
*/
|
||||
authenticateRequest(headers: any): AuthResult {
|
||||
// Try to get token from Authorization header
|
||||
const authHeader = headers.authorization || headers.Authorization;
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
const token = authHeader.substring(7);
|
||||
return this.validateToken(token);
|
||||
}
|
||||
|
||||
// Try to get token from custom header
|
||||
const tokenHeader = headers['x-auth-token'] || headers['X-Auth-Token'];
|
||||
if (tokenHeader) {
|
||||
return this.validateToken(tokenHeader);
|
||||
}
|
||||
|
||||
// Create anonymous session if no token provided
|
||||
return this.createAnonymousSession({
|
||||
userAgent: headers['user-agent'],
|
||||
ipAddress: headers['x-forwarded-for'] || 'unknown'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup resources
|
||||
*/
|
||||
destroy(): void {
|
||||
if (this.tokenCleanupInterval) {
|
||||
clearInterval(this.tokenCleanupInterval);
|
||||
}
|
||||
|
||||
this.userTokens.clear();
|
||||
this.userSessions.clear();
|
||||
}
|
||||
}
|
62
app/remote-server/test-chrome-connection.js
Normal file
62
app/remote-server/test-chrome-connection.js
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Test Chrome extension connection to remote server
|
||||
*/
|
||||
|
||||
import WebSocket from 'ws';
|
||||
|
||||
const CHROME_ENDPOINT = 'ws://localhost:3001/chrome';
|
||||
|
||||
async function testChromeConnection() {
|
||||
console.log('🔌 Testing Chrome extension connection...');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(CHROME_ENDPOINT);
|
||||
|
||||
ws.on('open', () => {
|
||||
console.log('✅ Connected to Chrome extension endpoint');
|
||||
|
||||
// Send a test message to see if any Chrome extensions are connected
|
||||
const testMessage = {
|
||||
id: 'test-' + Date.now(),
|
||||
action: 'callTool',
|
||||
params: {
|
||||
name: 'chrome_navigate',
|
||||
arguments: {
|
||||
url: 'https://www.google.com',
|
||||
newWindow: false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
console.log('📤 Sending test message:', JSON.stringify(testMessage, null, 2));
|
||||
ws.send(JSON.stringify(testMessage));
|
||||
|
||||
// Set a timeout to close the connection
|
||||
setTimeout(() => {
|
||||
console.log('⏰ Test timeout - closing connection');
|
||||
ws.close();
|
||||
resolve('Test completed');
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const response = JSON.parse(data.toString());
|
||||
console.log('📨 Received response:', JSON.stringify(response, null, 2));
|
||||
} catch (error) {
|
||||
console.error('❌ Error parsing response:', error);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error('❌ WebSocket error:', error);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('🔌 Chrome extension connection closed');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
testChromeConnection().catch(console.error);
|
49
app/remote-server/test-client.js
Normal file
49
app/remote-server/test-client.js
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Simple test client to verify the remote server is working
|
||||
*/
|
||||
|
||||
import WebSocket from 'ws';
|
||||
|
||||
const SERVER_URL = 'ws://localhost:3001/mcp';
|
||||
|
||||
console.log('🔌 Connecting to MCP Remote Server...');
|
||||
|
||||
const ws = new WebSocket(SERVER_URL);
|
||||
|
||||
ws.on('open', () => {
|
||||
console.log('✅ Connected to remote server!');
|
||||
|
||||
// Test listing tools
|
||||
console.log('📋 Requesting available tools...');
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
method: 'tools/list',
|
||||
params: {},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
console.log('📨 Received response:', JSON.stringify(message, null, 2));
|
||||
} catch (error) {
|
||||
console.error('❌ Error parsing message:', error);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('🔌 Connection closed');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error('❌ WebSocket error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Close connection after 5 seconds
|
||||
setTimeout(() => {
|
||||
console.log('⏰ Closing connection...');
|
||||
ws.close();
|
||||
}, 5000);
|
51
app/remote-server/test-connection-status.js
Normal file
51
app/remote-server/test-connection-status.js
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Monitor Chrome extension connections to the remote server
|
||||
*/
|
||||
|
||||
import WebSocket from 'ws';
|
||||
|
||||
const CHROME_ENDPOINT = 'ws://localhost:3001/chrome';
|
||||
|
||||
function monitorConnections() {
|
||||
console.log('🔍 Monitoring Chrome extension connections...');
|
||||
console.log('📍 Endpoint:', CHROME_ENDPOINT);
|
||||
console.log('');
|
||||
console.log('Instructions:');
|
||||
console.log('1. Load the Chrome extension from: app/chrome-extension/.output/chrome-mv3');
|
||||
console.log('2. Open the extension popup to check connection status');
|
||||
console.log('3. Watch this monitor for connection events');
|
||||
console.log('');
|
||||
|
||||
const ws = new WebSocket(CHROME_ENDPOINT);
|
||||
|
||||
ws.on('open', () => {
|
||||
console.log('✅ Connected to Chrome extension endpoint');
|
||||
console.log('⏳ Waiting for Chrome extension to connect...');
|
||||
});
|
||||
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
console.log('📨 Received message from Chrome extension:', JSON.stringify(message, null, 2));
|
||||
} catch (error) {
|
||||
console.log('📨 Received raw message:', data.toString());
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error('❌ WebSocket error:', error);
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('🔌 Connection closed');
|
||||
});
|
||||
|
||||
// Keep the connection alive
|
||||
setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
console.log('💓 Connection still alive, waiting for Chrome extension...');
|
||||
}
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
monitorConnections();
|
25
app/remote-server/test-health.js
Normal file
25
app/remote-server/test-health.js
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Simple health check test
|
||||
*/
|
||||
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
const SERVER_URL = 'http://localhost:3001';
|
||||
|
||||
async function testHealth() {
|
||||
try {
|
||||
console.log('🔍 Testing health endpoint...');
|
||||
const response = await fetch(`${SERVER_URL}/health`);
|
||||
|
||||
console.log('Status:', response.status);
|
||||
console.log('Headers:', Object.fromEntries(response.headers.entries()));
|
||||
|
||||
const data = await response.json();
|
||||
console.log('Response:', data);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
testHealth();
|
230
app/remote-server/test-multi-user-livekit.js
Normal file
230
app/remote-server/test-multi-user-livekit.js
Normal file
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Test script for multi-user Chrome extension to LiveKit agent integration
|
||||
* This script simulates multiple Chrome extension connections and verifies
|
||||
* that LiveKit agents are automatically started for each user
|
||||
*/
|
||||
|
||||
import WebSocket from 'ws';
|
||||
|
||||
const SERVER_URL = 'ws://localhost:3001/chrome';
|
||||
const NUM_USERS = 3;
|
||||
|
||||
class TestChromeUser {
|
||||
constructor(userId) {
|
||||
this.userId = userId;
|
||||
this.ws = null;
|
||||
this.sessionInfo = null;
|
||||
this.connected = false;
|
||||
this.liveKitAgentStarted = false;
|
||||
}
|
||||
|
||||
async connect() {
|
||||
return new Promise((resolve, reject) => {
|
||||
console.log(`👤 User ${this.userId}: Connecting Chrome extension...`);
|
||||
|
||||
this.ws = new WebSocket(SERVER_URL);
|
||||
|
||||
this.ws.on('open', () => {
|
||||
console.log(`✅ User ${this.userId}: Chrome extension connected`);
|
||||
this.connected = true;
|
||||
|
||||
// Send connection info (simulating Chrome extension)
|
||||
const connectionInfo = {
|
||||
type: 'connection_info',
|
||||
userAgent: `TestChromeUser-${this.userId}`,
|
||||
timestamp: Date.now(),
|
||||
extensionId: `test-extension-${this.userId}`
|
||||
};
|
||||
|
||||
this.ws.send(JSON.stringify(connectionInfo));
|
||||
});
|
||||
|
||||
this.ws.on('message', (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
|
||||
if (message.type === 'session_info') {
|
||||
this.sessionInfo = message.sessionInfo;
|
||||
console.log(`📋 User ${this.userId}: Received session info:`, {
|
||||
userId: this.sessionInfo.userId,
|
||||
sessionId: this.sessionInfo.sessionId,
|
||||
connectionId: this.sessionInfo.connectionId
|
||||
});
|
||||
|
||||
// Check if LiveKit agent should be starting
|
||||
console.log(`🚀 User ${this.userId}: LiveKit agent should be starting for room: mcp-chrome-user-${this.sessionInfo.userId}`);
|
||||
this.liveKitAgentStarted = true;
|
||||
|
||||
resolve();
|
||||
} else {
|
||||
console.log(`📨 User ${this.userId}: Received message:`, message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ User ${this.userId}: Error parsing message:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.on('close', () => {
|
||||
console.log(`🔌 User ${this.userId}: Chrome extension disconnected`);
|
||||
this.connected = false;
|
||||
});
|
||||
|
||||
this.ws.on('error', (error) => {
|
||||
console.error(`❌ User ${this.userId}: Connection error:`, error);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
// Timeout after 10 seconds
|
||||
setTimeout(() => {
|
||||
if (!this.sessionInfo) {
|
||||
reject(new Error(`User ${this.userId}: Timeout waiting for session info`));
|
||||
}
|
||||
}, 10000);
|
||||
});
|
||||
}
|
||||
|
||||
async sendTestCommand() {
|
||||
if (!this.connected || !this.ws) {
|
||||
throw new Error(`User ${this.userId}: Not connected`);
|
||||
}
|
||||
|
||||
const testCommand = {
|
||||
action: 'callTool',
|
||||
params: {
|
||||
name: 'chrome_navigate',
|
||||
arguments: { url: `https://example.com?user=${this.userId}` }
|
||||
},
|
||||
id: `test_${this.userId}_${Date.now()}`
|
||||
};
|
||||
|
||||
console.log(`🌐 User ${this.userId}: Sending navigation command`);
|
||||
this.ws.send(JSON.stringify(testCommand));
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.ws) {
|
||||
console.log(`👋 User ${this.userId}: Disconnecting Chrome extension`);
|
||||
this.ws.close();
|
||||
}
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
return {
|
||||
userId: this.userId,
|
||||
connected: this.connected,
|
||||
sessionInfo: this.sessionInfo,
|
||||
liveKitAgentStarted: this.liveKitAgentStarted,
|
||||
expectedRoom: this.sessionInfo ? `mcp-chrome-user-${this.sessionInfo.userId}` : null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function testMultiUserLiveKitIntegration() {
|
||||
console.log('🚀 Testing Multi-User Chrome Extension to LiveKit Agent Integration\n');
|
||||
console.log(`📊 Creating ${NUM_USERS} simulated Chrome extension users...\n`);
|
||||
|
||||
const users = [];
|
||||
|
||||
try {
|
||||
// Create and connect multiple users
|
||||
for (let i = 1; i <= NUM_USERS; i++) {
|
||||
const user = new TestChromeUser(i);
|
||||
users.push(user);
|
||||
|
||||
console.log(`\n--- Connecting User ${i} ---`);
|
||||
await user.connect();
|
||||
|
||||
// Wait a bit between connections to see the sequential startup
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
}
|
||||
|
||||
console.log('\n🎉 All Chrome extensions connected successfully!');
|
||||
|
||||
// Display session summary
|
||||
console.log('\n📊 SESSION AND LIVEKIT AGENT SUMMARY:');
|
||||
console.log('=' * 80);
|
||||
|
||||
users.forEach(user => {
|
||||
const status = user.getStatus();
|
||||
console.log(`👤 User ${status.userId}:`);
|
||||
console.log(` 📋 Session ID: ${status.sessionInfo?.sessionId || 'N/A'}`);
|
||||
console.log(` 🆔 User ID: ${status.sessionInfo?.userId || 'N/A'}`);
|
||||
console.log(` 🏠 Expected LiveKit Room: ${status.expectedRoom || 'N/A'}`);
|
||||
console.log(` 🚀 LiveKit Agent Started: ${status.liveKitAgentStarted ? '✅ YES' : '❌ NO'}`);
|
||||
console.log('');
|
||||
});
|
||||
|
||||
// Test sending commands from each user
|
||||
console.log('\n--- Testing Commands from Each User ---');
|
||||
for (const user of users) {
|
||||
await user.sendTestCommand();
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
// Wait for responses and LiveKit agent startup
|
||||
console.log('\n⏳ Waiting for LiveKit agents to start and process commands...');
|
||||
await new Promise(resolve => setTimeout(resolve, 10000));
|
||||
|
||||
// Test session isolation
|
||||
console.log('\n🔍 Session Isolation Test:');
|
||||
const sessionIds = users.map(user => user.sessionInfo?.sessionId).filter(Boolean);
|
||||
const userIds = users.map(user => user.sessionInfo?.userId).filter(Boolean);
|
||||
const uniqueSessionIds = new Set(sessionIds);
|
||||
const uniqueUserIds = new Set(userIds);
|
||||
|
||||
console.log(` Total users: ${users.length}`);
|
||||
console.log(` Unique session IDs: ${uniqueSessionIds.size}`);
|
||||
console.log(` Unique user IDs: ${uniqueUserIds.size}`);
|
||||
console.log(` Session isolation: ${uniqueSessionIds.size === users.length ? '✅ PASS' : '❌ FAIL'}`);
|
||||
console.log(` User ID isolation: ${uniqueUserIds.size === users.length ? '✅ PASS' : '❌ FAIL'}`);
|
||||
|
||||
// Test LiveKit room naming
|
||||
console.log('\n🏠 LiveKit Room Naming Test:');
|
||||
const expectedRooms = users.map(user => user.getStatus().expectedRoom).filter(Boolean);
|
||||
const uniqueRooms = new Set(expectedRooms);
|
||||
|
||||
console.log(` Expected rooms: ${expectedRooms.length}`);
|
||||
console.log(` Unique rooms: ${uniqueRooms.size}`);
|
||||
console.log(` Room isolation: ${uniqueRooms.size === users.length ? '✅ PASS' : '❌ FAIL'}`);
|
||||
|
||||
expectedRooms.forEach((room, index) => {
|
||||
console.log(` User ${index + 1} → Room: ${room}`);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Test failed:', error);
|
||||
} finally {
|
||||
// Clean up connections
|
||||
console.log('\n🧹 Cleaning up connections...');
|
||||
users.forEach(user => user.disconnect());
|
||||
|
||||
setTimeout(() => {
|
||||
console.log('\n✅ Multi-user LiveKit integration test completed');
|
||||
console.log('\n📝 Expected Results:');
|
||||
console.log(' - Each Chrome extension gets a unique session ID');
|
||||
console.log(' - Each user gets a unique LiveKit room (mcp-chrome-user-{userId})');
|
||||
console.log(' - LiveKit agents start automatically for each Chrome connection');
|
||||
console.log(' - Commands are routed to the correct user\'s Chrome extension');
|
||||
console.log(' - LiveKit agents stop when Chrome extensions disconnect');
|
||||
|
||||
process.exit(0);
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if server is running
|
||||
console.log('🔍 Checking if remote server is running...');
|
||||
const testWs = new WebSocket(SERVER_URL);
|
||||
|
||||
testWs.on('open', () => {
|
||||
testWs.close();
|
||||
console.log('✅ Remote server is running, starting multi-user LiveKit test...\n');
|
||||
testMultiUserLiveKitIntegration();
|
||||
});
|
||||
|
||||
testWs.on('error', (error) => {
|
||||
console.error('❌ Cannot connect to remote server. Please start the remote server first:');
|
||||
console.error(' cd app/remote-server && npm start');
|
||||
console.error('\nError:', error.message);
|
||||
process.exit(1);
|
||||
});
|
219
app/remote-server/test-multi-user.js
Normal file
219
app/remote-server/test-multi-user.js
Normal file
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* Test script for multi-user session management
|
||||
* This script simulates multiple Chrome extension connections to test session isolation
|
||||
*/
|
||||
|
||||
import WebSocket from 'ws';
|
||||
|
||||
const SERVER_URL = 'ws://localhost:3001/chrome';
|
||||
const NUM_CONNECTIONS = 3;
|
||||
|
||||
class TestConnection {
|
||||
constructor(id) {
|
||||
this.id = id;
|
||||
this.ws = null;
|
||||
this.sessionInfo = null;
|
||||
this.connected = false;
|
||||
}
|
||||
|
||||
async connect() {
|
||||
return new Promise((resolve, reject) => {
|
||||
console.log(`🔌 Connection ${this.id}: Connecting to ${SERVER_URL}`);
|
||||
|
||||
this.ws = new WebSocket(SERVER_URL);
|
||||
|
||||
this.ws.on('open', () => {
|
||||
console.log(`✅ Connection ${this.id}: Connected`);
|
||||
this.connected = true;
|
||||
|
||||
// Send connection info
|
||||
const connectionInfo = {
|
||||
type: 'connection_info',
|
||||
userAgent: `TestAgent-${this.id}`,
|
||||
timestamp: Date.now(),
|
||||
extensionId: `test-extension-${this.id}`
|
||||
};
|
||||
|
||||
this.ws.send(JSON.stringify(connectionInfo));
|
||||
});
|
||||
|
||||
this.ws.on('message', (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
|
||||
if (message.type === 'session_info') {
|
||||
this.sessionInfo = message.sessionInfo;
|
||||
console.log(`📋 Connection ${this.id}: Received session info:`, this.sessionInfo);
|
||||
resolve();
|
||||
} else {
|
||||
console.log(`📨 Connection ${this.id}: Received message:`, message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ Connection ${this.id}: Error parsing message:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.on('close', () => {
|
||||
console.log(`🔌 Connection ${this.id}: Disconnected`);
|
||||
this.connected = false;
|
||||
});
|
||||
|
||||
this.ws.on('error', (error) => {
|
||||
console.error(`❌ Connection ${this.id}: Error:`, error);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
// Timeout after 5 seconds
|
||||
setTimeout(() => {
|
||||
if (!this.sessionInfo) {
|
||||
reject(new Error(`Connection ${this.id}: Timeout waiting for session info`));
|
||||
}
|
||||
}, 5000);
|
||||
});
|
||||
}
|
||||
|
||||
async sendTestMessage() {
|
||||
if (!this.connected || !this.ws) {
|
||||
throw new Error(`Connection ${this.id}: Not connected`);
|
||||
}
|
||||
|
||||
const testMessage = {
|
||||
action: 'callTool',
|
||||
params: {
|
||||
name: 'chrome_navigate',
|
||||
arguments: { url: `https://example.com?user=${this.id}` }
|
||||
},
|
||||
id: `test_${this.id}_${Date.now()}`
|
||||
};
|
||||
|
||||
console.log(`📤 Connection ${this.id}: Sending test message:`, testMessage);
|
||||
this.ws.send(JSON.stringify(testMessage));
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function testMultiUserSessions() {
|
||||
console.log('🚀 Starting multi-user session test...\n');
|
||||
|
||||
const connections = [];
|
||||
|
||||
try {
|
||||
// Create and connect multiple test connections
|
||||
for (let i = 1; i <= NUM_CONNECTIONS; i++) {
|
||||
const connection = new TestConnection(i);
|
||||
connections.push(connection);
|
||||
|
||||
console.log(`\n--- Connecting User ${i} ---`);
|
||||
await connection.connect();
|
||||
|
||||
// Wait a bit between connections
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
console.log('\n🎉 All connections established successfully!');
|
||||
console.log('\n📊 Session Summary:');
|
||||
connections.forEach(conn => {
|
||||
console.log(` User ${conn.id}: Session ${conn.sessionInfo.sessionId}, User ID: ${conn.sessionInfo.userId}`);
|
||||
});
|
||||
|
||||
// Test sending messages from each connection
|
||||
console.log('\n--- Testing Message Routing ---');
|
||||
for (const connection of connections) {
|
||||
await connection.sendTestMessage();
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
// Wait for responses
|
||||
console.log('\n⏳ Waiting for responses...');
|
||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||
|
||||
// Test session isolation by checking unique session IDs
|
||||
const sessionIds = connections.map(conn => conn.sessionInfo.sessionId);
|
||||
const uniqueSessionIds = new Set(sessionIds);
|
||||
|
||||
console.log('\n🔍 Session Isolation Test:');
|
||||
console.log(` Total connections: ${connections.length}`);
|
||||
console.log(` Unique session IDs: ${uniqueSessionIds.size}`);
|
||||
console.log(` Session isolation: ${uniqueSessionIds.size === connections.length ? '✅ PASS' : '❌ FAIL'}`);
|
||||
|
||||
// Test user ID uniqueness
|
||||
const userIds = connections.map(conn => conn.sessionInfo.userId);
|
||||
const uniqueUserIds = new Set(userIds);
|
||||
|
||||
console.log(` Unique user IDs: ${uniqueUserIds.size}`);
|
||||
console.log(` User ID isolation: ${uniqueUserIds.size === connections.length ? '✅ PASS' : '❌ FAIL'}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Test failed:', error);
|
||||
} finally {
|
||||
// Clean up connections
|
||||
console.log('\n🧹 Cleaning up connections...');
|
||||
connections.forEach(conn => conn.disconnect());
|
||||
|
||||
setTimeout(() => {
|
||||
console.log('✅ Test completed');
|
||||
process.exit(0);
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
async function testSessionPersistence() {
|
||||
console.log('\n🔄 Testing session persistence...');
|
||||
|
||||
const connection = new TestConnection('persistence');
|
||||
|
||||
try {
|
||||
await connection.connect();
|
||||
const originalSessionId = connection.sessionInfo.sessionId;
|
||||
|
||||
console.log(`📋 Original session: ${originalSessionId}`);
|
||||
|
||||
// Disconnect and reconnect
|
||||
connection.disconnect();
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
await connection.connect();
|
||||
const newSessionId = connection.sessionInfo.sessionId;
|
||||
|
||||
console.log(`📋 New session: ${newSessionId}`);
|
||||
console.log(`🔄 Session persistence: ${originalSessionId === newSessionId ? '❌ FAIL (sessions should be different)' : '✅ PASS (new session created)'}`);
|
||||
|
||||
connection.disconnect();
|
||||
} catch (error) {
|
||||
console.error('❌ Session persistence test failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Run tests
|
||||
async function runAllTests() {
|
||||
try {
|
||||
await testMultiUserSessions();
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
await testSessionPersistence();
|
||||
} catch (error) {
|
||||
console.error('❌ Tests failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if server is running
|
||||
console.log('🔍 Checking if remote server is running...');
|
||||
const testWs = new WebSocket(SERVER_URL);
|
||||
|
||||
testWs.on('open', () => {
|
||||
testWs.close();
|
||||
console.log('✅ Server is running, starting tests...\n');
|
||||
runAllTests();
|
||||
});
|
||||
|
||||
testWs.on('error', (error) => {
|
||||
console.error('❌ Cannot connect to server. Please start the remote server first:');
|
||||
console.error(' cd app/remote-server && npm run dev');
|
||||
console.error('\nError:', error.message);
|
||||
process.exit(1);
|
||||
});
|
58
app/remote-server/test-simple-mcp.js
Normal file
58
app/remote-server/test-simple-mcp.js
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Simple MCP endpoint test
|
||||
*/
|
||||
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
const SERVER_URL = 'http://localhost:3001';
|
||||
|
||||
async function testMcpEndpoint() {
|
||||
try {
|
||||
console.log('🔍 Testing MCP endpoint with simple request...');
|
||||
|
||||
const initMessage = {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
clientInfo: {
|
||||
name: 'test-simple-mcp-client',
|
||||
version: '1.0.0',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
console.log('📤 Sending:', JSON.stringify(initMessage, null, 2));
|
||||
|
||||
const response = await fetch(`${SERVER_URL}/mcp`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json, text/event-stream',
|
||||
},
|
||||
body: JSON.stringify(initMessage),
|
||||
});
|
||||
|
||||
console.log('📥 Status:', response.status);
|
||||
console.log('📥 Headers:', Object.fromEntries(response.headers.entries()));
|
||||
|
||||
if (response.ok) {
|
||||
const sessionId = response.headers.get('mcp-session-id');
|
||||
console.log('🆔 Session ID:', sessionId);
|
||||
|
||||
const text = await response.text();
|
||||
console.log('📥 SSE Response:', text);
|
||||
} else {
|
||||
const text = await response.text();
|
||||
console.log('📥 Error response:', text);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
testMcpEndpoint();
|
85
app/remote-server/test-sse-client.js
Normal file
85
app/remote-server/test-sse-client.js
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Test client for SSE (Server-Sent Events) streaming connection
|
||||
*/
|
||||
|
||||
import { EventSource } from 'eventsource';
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
const SERVER_URL = 'http://localhost:3001';
|
||||
const SSE_URL = `${SERVER_URL}/sse`;
|
||||
const MESSAGES_URL = `${SERVER_URL}/messages`;
|
||||
|
||||
console.log('🔌 Testing SSE streaming connection...');
|
||||
|
||||
let sessionId = null;
|
||||
|
||||
// Create SSE connection
|
||||
const eventSource = new EventSource(SSE_URL);
|
||||
|
||||
eventSource.onopen = () => {
|
||||
console.log('✅ SSE connection established!');
|
||||
};
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log('📨 Received SSE message:', JSON.stringify(data, null, 2));
|
||||
|
||||
// Extract session ID from the first message
|
||||
if (data.sessionId && !sessionId) {
|
||||
sessionId = data.sessionId;
|
||||
console.log(`🆔 Session ID: ${sessionId}`);
|
||||
|
||||
// Test listing tools after connection is established
|
||||
setTimeout(() => testListTools(), 1000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('📨 Received SSE data:', event.data);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('❌ SSE error:', error);
|
||||
};
|
||||
|
||||
async function testListTools() {
|
||||
if (!sessionId) {
|
||||
console.error('❌ No session ID available');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('📋 Testing tools/list via SSE...');
|
||||
|
||||
const message = {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'tools/list',
|
||||
params: {},
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(MESSAGES_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Session-ID': sessionId,
|
||||
},
|
||||
body: JSON.stringify(message),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('❌ Failed to send message:', response.status, response.statusText);
|
||||
} else {
|
||||
console.log('✅ Message sent successfully');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error sending message:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Close connection after 10 seconds
|
||||
setTimeout(() => {
|
||||
console.log('⏰ Closing SSE connection...');
|
||||
eventSource.close();
|
||||
process.exit(0);
|
||||
}, 10000);
|
132
app/remote-server/test-streamable-http-client.js
Normal file
132
app/remote-server/test-streamable-http-client.js
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Test client for Streamable HTTP connection
|
||||
*/
|
||||
|
||||
import fetch from 'node-fetch';
|
||||
import { EventSource } from 'eventsource';
|
||||
|
||||
const SERVER_URL = 'http://localhost:3001';
|
||||
const MCP_URL = `${SERVER_URL}/mcp`;
|
||||
|
||||
console.log('🔌 Testing Streamable HTTP connection...');
|
||||
|
||||
let sessionId = null;
|
||||
|
||||
async function testStreamableHttp() {
|
||||
try {
|
||||
// Step 1: Send initialization request
|
||||
console.log('🚀 Sending initialization request...');
|
||||
|
||||
const initMessage = {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
clientInfo: {
|
||||
name: 'test-streamable-http-client',
|
||||
version: '1.0.0',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const initResponse = await fetch(MCP_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json, text/event-stream',
|
||||
},
|
||||
body: JSON.stringify(initMessage),
|
||||
});
|
||||
|
||||
if (!initResponse.ok) {
|
||||
throw new Error(`Initialization failed: ${initResponse.status} ${initResponse.statusText}`);
|
||||
}
|
||||
|
||||
// Extract session ID from response headers
|
||||
sessionId = initResponse.headers.get('mcp-session-id');
|
||||
console.log(`✅ Initialization successful! Session ID: ${sessionId}`);
|
||||
|
||||
const initResult = await initResponse.text();
|
||||
console.log('📨 Initialization response (SSE):', initResult);
|
||||
|
||||
// Step 2: Establish SSE stream for this session
|
||||
console.log('🔌 Establishing SSE stream...');
|
||||
|
||||
const eventSource = new EventSource(MCP_URL, {
|
||||
headers: {
|
||||
'MCP-Session-ID': sessionId,
|
||||
},
|
||||
});
|
||||
|
||||
eventSource.onopen = () => {
|
||||
console.log('✅ SSE stream established!');
|
||||
// Test listing tools after stream is ready
|
||||
setTimeout(() => testListTools(), 1000);
|
||||
};
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log('📨 Received streaming message:', JSON.stringify(data, null, 2));
|
||||
} catch (error) {
|
||||
console.log('📨 Received streaming data:', event.data);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('❌ SSE stream error:', error);
|
||||
};
|
||||
|
||||
// Close after 10 seconds
|
||||
setTimeout(() => {
|
||||
console.log('⏰ Closing connections...');
|
||||
eventSource.close();
|
||||
process.exit(0);
|
||||
}, 10000);
|
||||
} catch (error) {
|
||||
console.error('❌ Error in streamable HTTP test:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function testListTools() {
|
||||
if (!sessionId) {
|
||||
console.error('❌ No session ID available');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('📋 Testing tools/list via Streamable HTTP...');
|
||||
|
||||
const message = {
|
||||
jsonrpc: '2.0',
|
||||
id: 2,
|
||||
method: 'tools/list',
|
||||
params: {},
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(MCP_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'MCP-Session-ID': sessionId,
|
||||
},
|
||||
body: JSON.stringify(message),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('❌ Failed to send tools/list:', response.status, response.statusText);
|
||||
} else {
|
||||
console.log('✅ tools/list message sent successfully');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error sending tools/list:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Start the test
|
||||
testStreamableHttp();
|
77
app/remote-server/test-tool-call.js
Normal file
77
app/remote-server/test-tool-call.js
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Test tool call to verify Chrome extension connection using MCP WebSocket
|
||||
*/
|
||||
|
||||
import WebSocket from 'ws';
|
||||
|
||||
const MCP_SERVER_URL = 'ws://localhost:3001/ws/mcp';
|
||||
|
||||
async function testToolCall() {
|
||||
console.log('🔌 Testing tool call via MCP WebSocket...');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(MCP_SERVER_URL);
|
||||
|
||||
ws.on('open', () => {
|
||||
console.log('✅ Connected to MCP WebSocket');
|
||||
|
||||
// Send a proper MCP tool call
|
||||
const message = {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'chrome_navigate',
|
||||
arguments: {
|
||||
url: 'https://www.google.com',
|
||||
newWindow: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
console.log('📤 Sending MCP message:', JSON.stringify(message, null, 2));
|
||||
ws.send(JSON.stringify(message));
|
||||
});
|
||||
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const response = JSON.parse(data.toString());
|
||||
console.log('📨 MCP Response:', JSON.stringify(response, null, 2));
|
||||
|
||||
if (response.error) {
|
||||
console.error('❌ Tool call failed:', response.error);
|
||||
reject(new Error(response.error.message || response.error));
|
||||
} else if (response.result) {
|
||||
console.log('✅ Tool call successful!');
|
||||
resolve(response.result);
|
||||
} else {
|
||||
console.log('📨 Received other message:', response);
|
||||
}
|
||||
ws.close();
|
||||
} catch (error) {
|
||||
console.error('❌ Error parsing response:', error);
|
||||
reject(error);
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error('❌ WebSocket error:', error);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('🔌 WebSocket connection closed');
|
||||
});
|
||||
|
||||
// Timeout after 10 seconds
|
||||
setTimeout(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.close();
|
||||
reject(new Error('Test timeout'));
|
||||
}
|
||||
}, 10000);
|
||||
});
|
||||
}
|
||||
|
||||
testToolCall().catch(console.error);
|
112
app/remote-server/test-tools-list.js
Normal file
112
app/remote-server/test-tools-list.js
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Test tools/list via streamable HTTP
|
||||
*/
|
||||
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
const SERVER_URL = 'http://localhost:3001';
|
||||
const MCP_URL = `${SERVER_URL}/mcp`;
|
||||
|
||||
async function testToolsList() {
|
||||
try {
|
||||
console.log('🔍 Testing tools/list via streamable HTTP...');
|
||||
|
||||
// Step 1: Initialize session
|
||||
const initMessage = {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {
|
||||
tools: {}
|
||||
},
|
||||
clientInfo: {
|
||||
name: 'test-tools-list-client',
|
||||
version: '1.0.0'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
console.log('🚀 Step 1: Initializing session...');
|
||||
const initResponse = await fetch(MCP_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json, text/event-stream'
|
||||
},
|
||||
body: JSON.stringify(initMessage)
|
||||
});
|
||||
|
||||
if (!initResponse.ok) {
|
||||
throw new Error(`Initialization failed: ${initResponse.status} ${initResponse.statusText}`);
|
||||
}
|
||||
|
||||
const sessionId = initResponse.headers.get('mcp-session-id');
|
||||
console.log(`✅ Session initialized! Session ID: ${sessionId}`);
|
||||
|
||||
// Step 2: Send tools/list request
|
||||
const toolsListMessage = {
|
||||
jsonrpc: '2.0',
|
||||
id: 2,
|
||||
method: 'tools/list',
|
||||
params: {}
|
||||
};
|
||||
|
||||
console.log('📋 Step 2: Requesting tools list...');
|
||||
const toolsResponse = await fetch(MCP_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json, text/event-stream',
|
||||
'MCP-Session-ID': sessionId
|
||||
},
|
||||
body: JSON.stringify(toolsListMessage)
|
||||
});
|
||||
|
||||
if (!toolsResponse.ok) {
|
||||
throw new Error(`Tools list failed: ${toolsResponse.status} ${toolsResponse.statusText}`);
|
||||
}
|
||||
|
||||
const toolsResult = await toolsResponse.text();
|
||||
console.log('📋 Tools list response (SSE):', toolsResult);
|
||||
|
||||
// Step 3: Test a tool call (navigate_to_url)
|
||||
const toolCallMessage = {
|
||||
jsonrpc: '2.0',
|
||||
id: 3,
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'navigate_to_url',
|
||||
arguments: {
|
||||
url: 'https://example.com'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
console.log('🔧 Step 3: Testing tool call (navigate_to_url)...');
|
||||
const toolCallResponse = await fetch(MCP_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json, text/event-stream',
|
||||
'MCP-Session-ID': sessionId
|
||||
},
|
||||
body: JSON.stringify(toolCallMessage)
|
||||
});
|
||||
|
||||
if (!toolCallResponse.ok) {
|
||||
throw new Error(`Tool call failed: ${toolCallResponse.status} ${toolCallResponse.statusText}`);
|
||||
}
|
||||
|
||||
const toolCallResult = await toolCallResponse.text();
|
||||
console.log('🔧 Tool call response (SSE):', toolCallResult);
|
||||
|
||||
console.log('✅ All tests completed successfully!');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
testToolsList();
|
27
app/remote-server/tsconfig.json
Normal file
27
app/remote-server/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "node",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"resolveJsonModule": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"**/*.test.ts"
|
||||
]
|
||||
}
|
Reference in New Issue
Block a user