Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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_<NAME>`: 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

Expand Down
272 changes: 272 additions & 0 deletions mcp-server/src/credential-store.ts
Original file line number Diff line number Diff line change
@@ -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_<NAME> (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<string, string> = 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<void> {
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<string | undefined> {
// 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<boolean> {
const value = await this.get(name);
return value !== undefined;
}

/**
* List available credential names (NOT values)
* Safe to return to the model
*/
async list(): Promise<CredentialInfo[]> {
await this.ensureLoaded();

const credentials: CredentialInfo[] = [];
const seen = new Set<string>();

// 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<void> {
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<boolean> {
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<void> {
const obj: Record<string, string> = {};
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<string> {
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<Map<string, string>> {
await this.ensureLoaded();

const valueToName = new Map<string, string>();

// 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;
}
Loading