Add session persistence system to maintain browser contexts across MCP tool calls: - SessionManager: Global persistent context management keyed by session ID - BrowserServerBackend: Modified to use session persistence and reuse contexts - Context: Enhanced to support environment introspection and session ID override - MCP Roots: Added educational tool descriptions for workspace-aware automation - Environment Detection: System file introspection for display/GPU/project detection Key features: - Browser contexts survive between tool calls preserving cache, cookies, state - Complete session isolation between different MCP clients - Zero startup overhead for repeat connections - Backward compatible with existing implementations - Support for MCP roots workspace detection and environment adaptation Tested and verified with real Claude Code client showing successful session persistence across navigation calls with preserved browser state. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
227 lines
6.2 KiB
TypeScript
227 lines
6.2 KiB
TypeScript
/**
|
|
* Copyright (c) Microsoft Corporation.
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
|
|
export interface EnvironmentCapabilities {
|
|
displays: DisplayInfo[];
|
|
gpu: GPUInfo;
|
|
projectDirectory?: string;
|
|
memory?: MemoryInfo;
|
|
}
|
|
|
|
export interface DisplayInfo {
|
|
socket: string;
|
|
display: string;
|
|
available: boolean;
|
|
}
|
|
|
|
export interface GPUInfo {
|
|
hasGPU: boolean;
|
|
hasRender: boolean;
|
|
devices: string[];
|
|
}
|
|
|
|
export interface MemoryInfo {
|
|
available: number;
|
|
total: number;
|
|
}
|
|
|
|
export class EnvironmentIntrospector {
|
|
private _currentRoots: { uri: string; name?: string }[] = [];
|
|
private _capabilities: EnvironmentCapabilities | null = null;
|
|
|
|
updateRoots(roots: { uri: string; name?: string }[]) {
|
|
this._currentRoots = roots;
|
|
this._capabilities = null; // Reset cached capabilities
|
|
}
|
|
|
|
getCurrentCapabilities(): EnvironmentCapabilities {
|
|
if (!this._capabilities)
|
|
this._capabilities = this._introspectEnvironment();
|
|
|
|
return this._capabilities;
|
|
}
|
|
|
|
private _introspectEnvironment(): EnvironmentCapabilities {
|
|
const capabilities: EnvironmentCapabilities = {
|
|
displays: [],
|
|
gpu: { hasGPU: false, hasRender: false, devices: [] }
|
|
};
|
|
|
|
for (const root of this._currentRoots) {
|
|
if (!root.uri.startsWith('file://'))
|
|
continue;
|
|
|
|
const rootPath = root.uri.slice(7); // Remove 'file://' prefix
|
|
|
|
try {
|
|
if (rootPath === '/tmp/.X11-unix') {
|
|
capabilities.displays = this._detectDisplays(rootPath);
|
|
} else if (rootPath === '/dev/dri') {
|
|
capabilities.gpu = this._detectGPU(rootPath);
|
|
} else if (rootPath === '/proc/meminfo') {
|
|
capabilities.memory = this._detectMemory(rootPath);
|
|
} else if (fs.statSync(rootPath).isDirectory() && !rootPath.startsWith('/dev') && !rootPath.startsWith('/proc') && !rootPath.startsWith('/sys') && !rootPath.startsWith('/tmp')) {
|
|
// Assume this is a project directory
|
|
if (!capabilities.projectDirectory)
|
|
capabilities.projectDirectory = rootPath;
|
|
|
|
}
|
|
} catch (error) {
|
|
// Ignore errors for inaccessible paths
|
|
}
|
|
}
|
|
|
|
return capabilities;
|
|
}
|
|
|
|
private _detectDisplays(x11Path: string): DisplayInfo[] {
|
|
try {
|
|
if (!fs.existsSync(x11Path))
|
|
return [];
|
|
|
|
const sockets = fs.readdirSync(x11Path);
|
|
return sockets
|
|
.filter(name => name.startsWith('X'))
|
|
.map(socket => {
|
|
const displayNumber = socket.slice(1);
|
|
return {
|
|
socket,
|
|
display: `:${displayNumber}`,
|
|
available: true
|
|
};
|
|
});
|
|
} catch (error) {
|
|
// Could not detect displays
|
|
return [];
|
|
}
|
|
}
|
|
|
|
private _detectGPU(driPath: string): GPUInfo {
|
|
try {
|
|
if (!fs.existsSync(driPath))
|
|
return { hasGPU: false, hasRender: false, devices: [] };
|
|
|
|
|
|
const devices = fs.readdirSync(driPath);
|
|
return {
|
|
hasGPU: devices.some(d => d.startsWith('card')),
|
|
hasRender: devices.some(d => d.startsWith('renderD')),
|
|
devices
|
|
};
|
|
} catch (error) {
|
|
// Could not detect GPU
|
|
return { hasGPU: false, hasRender: false, devices: [] };
|
|
}
|
|
}
|
|
|
|
private _detectMemory(meminfoPath: string): MemoryInfo | undefined {
|
|
try {
|
|
if (!fs.existsSync(meminfoPath))
|
|
return undefined;
|
|
|
|
const content = fs.readFileSync(meminfoPath, 'utf8');
|
|
const lines = content.split('\n');
|
|
|
|
let total = 0;
|
|
let available = 0;
|
|
|
|
for (const line of lines) {
|
|
if (line.startsWith('MemTotal:'))
|
|
total = parseInt(line.split(/\s+/)[1], 10) * 1024; // Convert from kB to bytes
|
|
else if (line.startsWith('MemAvailable:'))
|
|
available = parseInt(line.split(/\s+/)[1], 10) * 1024; // Convert from kB to bytes
|
|
|
|
}
|
|
|
|
return total > 0 ? { total, available } : undefined;
|
|
} catch (error) {
|
|
// Could not detect memory
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
getRecommendedBrowserOptions(): {
|
|
headless?: boolean;
|
|
recordVideo?: { dir: string };
|
|
env?: Record<string, string>;
|
|
args?: string[];
|
|
} {
|
|
const capabilities = this.getCurrentCapabilities();
|
|
const options: any = {};
|
|
|
|
// Display configuration
|
|
if (capabilities.displays.length > 0) {
|
|
options.headless = false;
|
|
options.env = {
|
|
DISPLAY: capabilities.displays[0].display
|
|
};
|
|
} else {
|
|
options.headless = true;
|
|
}
|
|
|
|
// Video recording directory
|
|
if (capabilities.projectDirectory) {
|
|
options.recordVideo = {
|
|
dir: path.join(capabilities.projectDirectory, 'playwright-videos')
|
|
};
|
|
}
|
|
|
|
// GPU acceleration
|
|
if (capabilities.gpu.hasGPU) {
|
|
options.args = options.args || [];
|
|
options.args.push('--enable-gpu');
|
|
if (capabilities.gpu.hasRender)
|
|
options.args.push('--enable-gpu-sandbox');
|
|
|
|
}
|
|
|
|
return options;
|
|
}
|
|
|
|
getEnvironmentSummary(): string {
|
|
const capabilities = this.getCurrentCapabilities();
|
|
const summary: string[] = [];
|
|
|
|
if (capabilities.displays.length > 0)
|
|
summary.push(`Displays: ${capabilities.displays.map(d => d.display).join(', ')}`);
|
|
else
|
|
summary.push('No displays detected (headless mode)');
|
|
|
|
|
|
if (capabilities.gpu.hasGPU)
|
|
summary.push(`GPU: Available (${capabilities.gpu.devices.join(', ')})`);
|
|
else
|
|
summary.push('GPU: Not available');
|
|
|
|
|
|
if (capabilities.projectDirectory)
|
|
summary.push(`Project: ${capabilities.projectDirectory}`);
|
|
else
|
|
summary.push('Project: No directory specified');
|
|
|
|
|
|
if (capabilities.memory) {
|
|
const availableGB = (capabilities.memory.available / 1024 / 1024 / 1024).toFixed(1);
|
|
summary.push(`Memory: ${availableGB}GB available`);
|
|
}
|
|
|
|
return summary.join(' | ');
|
|
}
|
|
}
|