diff --git a/CLAUDE.md b/CLAUDE.md index f8079a7..5b71580 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,6 +35,7 @@ mcproxy/ ├── mcp-server/ # Local MCP server (@mcproxy/mcp-server) │ ├── src/ │ │ ├── browser-client.ts # WebSocket client with heartbeat +│ │ ├── credential-store.ts # Secure credential storage and scrubbing │ │ ├── session-manager.ts # Session lifecycle management │ │ └── tools/index.ts # MCP tool definitions and handlers │ └── Dockerfile @@ -128,6 +129,114 @@ docker push saladtechnologies/misc:mcproxy-browser-server - `MCPROXY_DEFAULT_ENDPOINT`: Optional. Default WebSocket endpoint. - `MCPROXY_HEARTBEAT_INTERVAL_MS`: Default 30000. Keepalive interval. - `MCPROXY_COMMAND_TIMEOUT_MS`: Default 30000. Command timeout. +- `MCPROXY_CREDENTIAL_`: Store credentials as env vars (e.g., `MCPROXY_CREDENTIAL_GITHUB_PASSWORD`). + +## Secure Credential Handling + +MCProxy supports secure credential handling so the AI model can use credentials (passwords, API keys, etc.) without ever seeing the actual values. + +### How It Works + +``` +Model: browser_type_credential(session_id, '#password', 'github_password') + ↓ +MCP Server: Resolves 'github_password' → actual value from local store + ↓ +Browser Server: Types actual value into the form field + ↓ +Model: Gets success confirmation (never sees the actual password) +``` + +### Setting Up Credentials + +**Option 1: Environment Variables (Recommended for production)** +```bash +export MCPROXY_CREDENTIAL_GITHUB_PASSWORD="my-secret-password" +export MCPROXY_CREDENTIAL_API_KEY="sk-123..." +``` + +**Option 2: Credentials File** +Create `~/.mcproxy/credentials.json`: +```json +{ + "github_password": "my-secret-password", + "api_key": "sk-123..." +} +``` + +### Credential Tools + +- `browser_list_credentials`: List available credential names (not values) +- `browser_has_credential`: Check if a specific credential exists (returns boolean) +- `browser_type_credential`: Type a credential into an input by selector +- `browser_keyboard_type_credential`: Type a credential at focused element +- `browser_set_credential`: Store a credential (for initial setup) +- `browser_delete_credential`: Remove a credential + +### Security Features + +1. **Reference-Only Access**: Model only sees credential names, never values +2. **Response Scrubbing**: All browser responses are automatically filtered to remove any credential values that might appear (e.g., in error messages or HTML) +3. **Local Storage**: Credentials are stored locally on the MCP server machine, never sent to AI providers +4. **File Permissions**: Credentials file is created with 600 permissions (owner-only read/write) + +### Login Flow Example + +Here's a recommended pattern for automated login that combines cookies (for session reuse) with credentials (as fallback): + +``` +1. Navigate to site +2. Check if already logged in (look for user menu, profile link, etc.) +3. If logged in → done (session cookies from previous login still valid) +4. If not logged in: + a. Check if credentials exist: browser_has_credential('site_password') + b. If no credentials → report error, cannot proceed + c. Navigate to login page + d. Type username: browser_type('input[name=email]', 'user@example.com') + e. Type password: browser_type_credential(session_id, 'input[name=password]', 'site_password') + f. Click submit + g. Wait for navigation/success indicator +5. Optionally save cookies for next time: browser_get_cookies() +``` + +**Pseudo-code for agent:** +``` +// Try to access authenticated page +navigate(dashboard_url) + +// Check login state +if page_has('.user-profile-menu'): + // Already logged in via session cookie + return success + +// Need to log in - check credentials available +if not browser_has_credential('mysite_password'): + return error("Credential 'mysite_password' not configured") + +// Perform login +navigate(login_url) +browser_type('#email', 'user@example.com') // Username can be in plain text +browser_type_credential(session_id, '#password', 'mysite_password') // Password by reference +browser_click('#submit') +wait_for_navigation() +``` + +### Cookies + Credentials Strategy + +For robust authentication, combine session cookies with credential fallback: + +| Scenario | Approach | +|----------|----------| +| **First login** | Use credentials to log in, save cookies for future | +| **Subsequent visits** | Session cookies auto-authenticate, no credential needed | +| **Session expired** | Cookies fail, fall back to credential login | +| **Different browser/location** | No cookies available, use credential login | + +**Best practices:** +- Store session cookies after successful login for reuse +- Use `browser_has_credential` to check availability before attempting login +- Prefer cookies when available (faster, no typing needed) +- Keep credentials as fallback for expired sessions or new contexts ## Important Conventions diff --git a/mcp-server/src/credential-store.ts b/mcp-server/src/credential-store.ts new file mode 100644 index 0000000..a204fab --- /dev/null +++ b/mcp-server/src/credential-store.ts @@ -0,0 +1,272 @@ +import { readFile, writeFile, mkdir } from 'fs/promises'; +import { existsSync } from 'fs'; +import { homedir } from 'os'; +import { join, dirname } from 'path'; + +/** + * Credential Store for MCProxy + * + * Stores credentials locally so the AI model can reference them by name + * without ever seeing the actual values. The model says "type credential X" + * and the MCP server resolves X to the actual value before sending to browser. + * + * Credential sources (in order of precedence): + * 1. Environment variables: MCPROXY_CREDENTIAL_ (uppercase, underscores) + * 2. Credentials file: ~/.mcproxy/credentials.json + * + * Example credentials.json: + * { + * "github_password": "my-secret-password", + * "api_key": "sk-123..." + * } + * + * Example env var: + * MCPROXY_CREDENTIAL_GITHUB_PASSWORD=my-secret-password + */ + +export interface CredentialInfo { + name: string; + source: 'env' | 'file'; + // Note: value is intentionally NOT included - never expose to model +} + +export class CredentialStore { + private credentialsPath: string; + private fileCredentials: Map = new Map(); + private loaded = false; + + constructor(credentialsPath?: string) { + this.credentialsPath = credentialsPath ?? join(homedir(), '.mcproxy', 'credentials.json'); + } + + /** + * Load credentials from file (lazy-loaded on first access) + */ + private async ensureLoaded(): Promise { + if (this.loaded) return; + + try { + if (existsSync(this.credentialsPath)) { + const content = await readFile(this.credentialsPath, 'utf-8'); + const parsed = JSON.parse(content); + + if (typeof parsed === 'object' && parsed !== null) { + for (const [key, value] of Object.entries(parsed)) { + if (typeof value === 'string') { + this.fileCredentials.set(key, value); + } + } + } + } + } catch (error) { + // File doesn't exist or is invalid - that's fine, we'll use env vars + console.error(`Warning: Could not load credentials file: ${error}`); + } + + this.loaded = true; + } + + /** + * Convert credential name to environment variable name + * github_password -> MCPROXY_CREDENTIAL_GITHUB_PASSWORD + */ + private toEnvVarName(name: string): string { + return `MCPROXY_CREDENTIAL_${name.toUpperCase().replace(/-/g, '_')}`; + } + + /** + * Get a credential value by name + * Returns undefined if credential doesn't exist + * + * IMPORTANT: This value should NEVER be returned to the model. + * It should only be used internally to send to the browser server. + */ + async get(name: string): Promise { + // Check environment variable first (higher precedence) + const envName = this.toEnvVarName(name); + const envValue = process.env[envName]; + if (envValue !== undefined) { + return envValue; + } + + // Check file credentials + await this.ensureLoaded(); + return this.fileCredentials.get(name); + } + + /** + * Check if a credential exists + */ + async has(name: string): Promise { + const value = await this.get(name); + return value !== undefined; + } + + /** + * List available credential names (NOT values) + * Safe to return to the model + */ + async list(): Promise { + await this.ensureLoaded(); + + const credentials: CredentialInfo[] = []; + const seen = new Set(); + + // Add env var credentials + const envPrefix = 'MCPROXY_CREDENTIAL_'; + for (const key of Object.keys(process.env)) { + if (key.startsWith(envPrefix)) { + const name = key.slice(envPrefix.length).toLowerCase().replace(/_/g, '-'); + credentials.push({ name, source: 'env' }); + seen.add(name); + } + } + + // Add file credentials (if not already from env) + for (const name of this.fileCredentials.keys()) { + if (!seen.has(name)) { + credentials.push({ name, source: 'file' }); + } + } + + return credentials.sort((a, b) => a.name.localeCompare(b.name)); + } + + /** + * Set a credential in the file store + * Creates the credentials file if it doesn't exist + */ + async set(name: string, value: string): Promise { + await this.ensureLoaded(); + + // Update in-memory cache + this.fileCredentials.set(name, value); + + // Write to file + await this.save(); + } + + /** + * Delete a credential from the file store + * Note: Cannot delete env var credentials + */ + async delete(name: string): Promise { + await this.ensureLoaded(); + + if (this.fileCredentials.has(name)) { + this.fileCredentials.delete(name); + await this.save(); + return true; + } + + return false; + } + + /** + * Save credentials to file + */ + private async save(): Promise { + const obj: Record = {}; + for (const [key, value] of this.fileCredentials) { + obj[key] = value; + } + + // Ensure directory exists + await mkdir(dirname(this.credentialsPath), { recursive: true }); + + // Write file with restricted permissions + await writeFile(this.credentialsPath, JSON.stringify(obj, null, 2), { mode: 0o600 }); + } + + /** + * Get the path to the credentials file + */ + getCredentialsPath(): string { + return this.credentialsPath; + } + + /** + * Scrub all known credential values from a string + * Replaces actual values with [CREDENTIAL:name] placeholders + * + * This provides defense-in-depth: even if a credential value somehow + * appears in a response (error message, HTML content, etc.), it will + * be filtered out before being returned to the model. + */ + async scrubCredentials(text: string): Promise { + await this.ensureLoaded(); + + let result = text; + + // Build a list of all credentials to scrub (env + file) + const credentialsToScrub: Array<{ name: string; value: string }> = []; + + // Add env var credentials + const envPrefix = 'MCPROXY_CREDENTIAL_'; + for (const [key, value] of Object.entries(process.env)) { + if (key.startsWith(envPrefix) && value) { + const name = key.slice(envPrefix.length).toLowerCase().replace(/_/g, '-'); + credentialsToScrub.push({ name, value }); + } + } + + // Add file credentials + for (const [name, value] of this.fileCredentials) { + credentialsToScrub.push({ name, value }); + } + + // Sort by value length descending to replace longer values first + // This prevents partial replacements (e.g., if one password contains another) + credentialsToScrub.sort((a, b) => b.value.length - a.value.length); + + // Replace each credential value with a placeholder + for (const { name, value } of credentialsToScrub) { + // Only scrub non-trivial values (at least 4 chars to avoid false positives) + if (value.length >= 4) { + // Use a global replace that handles special regex characters + const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + result = result.replace(new RegExp(escaped, 'g'), `[CREDENTIAL:${name}]`); + } + } + + return result; + } + + /** + * Get all credential values for scrubbing (internal use only) + * Returns a map of value -> name for efficient lookup + */ + async getValuesForScrubbing(): Promise> { + await this.ensureLoaded(); + + const valueToName = new Map(); + + // Add env var credentials + const envPrefix = 'MCPROXY_CREDENTIAL_'; + for (const [key, value] of Object.entries(process.env)) { + if (key.startsWith(envPrefix) && value && value.length >= 4) { + const name = key.slice(envPrefix.length).toLowerCase().replace(/_/g, '-'); + valueToName.set(value, name); + } + } + + // Add file credentials + for (const [name, value] of this.fileCredentials) { + if (value.length >= 4) { + valueToName.set(value, name); + } + } + + return valueToName; + } +} + +// Singleton instance +let defaultStore: CredentialStore | null = null; + +export function getCredentialStore(): CredentialStore { + if (!defaultStore) { + defaultStore = new CredentialStore(); + } + return defaultStore; +} diff --git a/mcp-server/src/session-manager.ts b/mcp-server/src/session-manager.ts index 4908f26..44c76a7 100644 --- a/mcp-server/src/session-manager.ts +++ b/mcp-server/src/session-manager.ts @@ -1,6 +1,7 @@ import { v4 as uuidv4 } from 'uuid'; import { BrowserClient } from './browser-client.js'; import type { CommandType, CommandParams, CreateContextResult, LocationInfo, BrowserType } from '@mcproxy/shared'; +import { getCredentialStore } from './credential-store.js'; interface Session { sessionId: string; @@ -12,6 +13,31 @@ interface Session { location: LocationInfo; } +/** + * Recursively scrub credential values from any value + * Handles strings, objects, and arrays + */ +async function scrubValue(value: unknown, credentialStore: ReturnType): Promise { + if (typeof value === 'string') { + return credentialStore.scrubCredentials(value); + } + + if (Array.isArray(value)) { + return Promise.all(value.map((item) => scrubValue(item, credentialStore))); + } + + if (value !== null && typeof value === 'object') { + const scrubbed: Record = {}; + for (const [key, val] of Object.entries(value)) { + scrubbed[key] = await scrubValue(val, credentialStore); + } + return scrubbed; + } + + // Numbers, booleans, null, undefined - return as-is + return value; +} + export class SessionManager { private sessions: Map = new Map(); private authToken: string; @@ -105,7 +131,12 @@ export class SessionManager { contextId: session.contextId, } as CommandParams; - return session.client.sendCommand(command, fullParams); + const result = await session.client.sendCommand(command, fullParams); + + // Scrub any credential values from the response before returning to model + // This provides defense-in-depth against accidental credential exposure + const credentialStore = getCredentialStore(); + return scrubValue(result, credentialStore); } listSessions(): Array<{ diff --git a/mcp-server/src/tools/index.ts b/mcp-server/src/tools/index.ts index d0dcbf0..3836c73 100644 --- a/mcp-server/src/tools/index.ts +++ b/mcp-server/src/tools/index.ts @@ -3,6 +3,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { writeFile, mkdir } from 'fs/promises'; import { dirname, resolve, isAbsolute } from 'path'; import type { SessionManager } from '../session-manager.js'; +import { getCredentialStore } from '../credential-store.js'; // MCP Server version info export const MCP_SERVER_VERSION = '1.2.0'; @@ -903,4 +904,324 @@ export function registerTools(server: McpServer, sessionManager: SessionManager) return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; } ); + + // ============================================ + // CREDENTIAL TOOLS (secure credential handling) + // These tools allow typing credentials by reference, keeping actual values hidden from the model + // ============================================ + + const credentialStore = getCredentialStore(); + + server.registerTool( + 'browser_list_credentials', + { + title: 'List Stored Credentials', + description: + 'List all available credential names that can be used with browser_type_credential. Returns names and their source (env var or file), but NEVER the actual values. Credentials are stored locally and resolved by the MCP server.', + inputSchema: {}, + annotations: { + readOnlyHint: true, + }, + }, + async () => { + const credentials = await credentialStore.list(); + const result = { + credentials, + credentialsFile: credentialStore.getCredentialsPath(), + usage: + 'Use credential names with browser_type_credential or browser_keyboard_type_credential to type sensitive values without exposing them.', + }; + return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; + } + ); + + server.registerTool( + 'browser_has_credential', + { + title: 'Check Credential Exists', + description: + 'Check if a specific credential is configured without listing all credentials. Returns true/false. Useful for conditional login flows.', + inputSchema: { + name: z.string().describe('Name of the credential to check (e.g., "github_password")'), + }, + annotations: { + readOnlyHint: true, + }, + }, + async ({ name }) => { + const exists = await credentialStore.has(name); + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + credential: name, + exists, + }, + null, + 2 + ), + }, + ], + }; + } + ); + + server.registerTool( + 'browser_type_credential', + { + title: 'Type Credential into Element', + description: + 'Type a stored credential into an input element BY REFERENCE. The actual credential value is never exposed to the model - it is resolved locally by the MCP server. Use browser_list_credentials to see available credential names.', + inputSchema: { + session_id: z.string().uuid().describe('Session ID'), + selector: z.string().describe('CSS selector of input element (e.g., "#password", "input[type=password]")'), + credential_name: z + .string() + .describe('Name of the credential to type (e.g., "github_password"). Use browser_list_credentials to see available names.'), + humanize: z.boolean().optional().describe('Humanize typing with random delays between keystrokes (50-150ms)'), + delay: z + .number() + .int() + .nonnegative() + .optional() + .describe('Fixed delay between keystrokes in ms (ignored if humanize is true)'), + }, + }, + async ({ session_id, selector, credential_name, humanize, delay }) => { + // Resolve credential locally - value never returned to model + const credentialValue = await credentialStore.get(credential_name); + if (!credentialValue) { + const available = await credentialStore.list(); + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + success: false, + error: `Credential '${credential_name}' not found`, + availableCredentials: available.map((c) => c.name), + hint: `Set via env var MCPROXY_CREDENTIAL_${credential_name.toUpperCase().replace(/-/g, '_')} or add to ${credentialStore.getCredentialsPath()}`, + }, + null, + 2 + ), + }, + ], + }; + } + + // Send actual value to browser server (model never sees this) + await sessionManager.sendCommand(session_id, 'type', { + selector, + text: credentialValue, + humanize, + delay, + }); + + // Return success without revealing the value + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + success: true, + credential: credential_name, + selector, + message: `Credential '${credential_name}' typed into ${selector}`, + // Explicitly NOT including the actual value + }, + null, + 2 + ), + }, + ], + }; + } + ); + + server.registerTool( + 'browser_keyboard_type_credential', + { + title: 'Type Credential at Focus', + description: + 'Type a stored credential at the currently focused element BY REFERENCE. Use this after clicking an input field with click_at. The actual credential value is never exposed to the model.', + inputSchema: { + session_id: z.string().uuid().describe('Session ID'), + credential_name: z + .string() + .describe('Name of the credential to type (e.g., "github_password"). Use browser_list_credentials to see available names.'), + humanize: z.boolean().optional().describe('Humanize typing with random delays between keystrokes (50-150ms)'), + delay: z + .number() + .int() + .nonnegative() + .optional() + .describe('Fixed delay between keystrokes in ms (ignored if humanize is true)'), + }, + }, + async ({ session_id, credential_name, humanize, delay }) => { + // Resolve credential locally - value never returned to model + const credentialValue = await credentialStore.get(credential_name); + if (!credentialValue) { + const available = await credentialStore.list(); + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + success: false, + error: `Credential '${credential_name}' not found`, + availableCredentials: available.map((c) => c.name), + hint: `Set via env var MCPROXY_CREDENTIAL_${credential_name.toUpperCase().replace(/-/g, '_')} or add to ${credentialStore.getCredentialsPath()}`, + }, + null, + 2 + ), + }, + ], + }; + } + + // Send actual value to browser server (model never sees this) + await sessionManager.sendCommand(session_id, 'keyboard_type', { + text: credentialValue, + humanize, + delay, + }); + + // Return success without revealing the value + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + success: true, + credential: credential_name, + message: `Credential '${credential_name}' typed at focused element`, + // Explicitly NOT including the actual value + }, + null, + 2 + ), + }, + ], + }; + } + ); + + server.registerTool( + 'browser_set_credential', + { + title: 'Store Credential', + description: + 'Store a credential for later use with browser_type_credential. The credential is saved to the local credentials file (~/.mcproxy/credentials.json). WARNING: Only use this for initial setup - prefer setting credentials via environment variables for production use.', + inputSchema: { + name: z.string().describe('Name for the credential (e.g., "github_password", "api_key")'), + value: z.string().describe('The credential value to store'), + }, + annotations: { + destructiveHint: true, + }, + }, + async ({ name, value }) => { + await credentialStore.set(name, value); + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + success: true, + credential: name, + storedIn: credentialStore.getCredentialsPath(), + message: `Credential '${name}' stored. Use browser_type_credential to type it securely.`, + // Explicitly NOT echoing back the value + }, + null, + 2 + ), + }, + ], + }; + } + ); + + server.registerTool( + 'browser_delete_credential', + { + title: 'Delete Credential', + description: + 'Delete a credential from the local credentials file. Note: Cannot delete credentials set via environment variables.', + inputSchema: { + name: z.string().describe('Name of the credential to delete'), + }, + annotations: { + destructiveHint: true, + }, + }, + async ({ name }) => { + const deleted = await credentialStore.delete(name); + if (deleted) { + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + success: true, + credential: name, + message: `Credential '${name}' deleted from ${credentialStore.getCredentialsPath()}`, + }, + null, + 2 + ), + }, + ], + }; + } else { + // Check if it exists in env + const envName = `MCPROXY_CREDENTIAL_${name.toUpperCase().replace(/-/g, '_')}`; + if (process.env[envName]) { + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + success: false, + credential: name, + error: `Credential '${name}' is set via environment variable ${envName}. Unset the env var to remove it.`, + }, + null, + 2 + ), + }, + ], + }; + } + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + success: false, + credential: name, + error: `Credential '${name}' not found`, + }, + null, + 2 + ), + }, + ], + }; + } + } + ); }