From 88053b3d912e7e158be5d858493a1ca54dbb8e3a Mon Sep 17 00:00:00 2001 From: Shawn Date: Mon, 12 Jan 2026 11:17:36 -0500 Subject: [PATCH 1/2] Add secure credential handling for browser automation Allows AI models to use credentials (passwords, API keys) without seeing the actual values. Credentials are stored locally and referenced by name. New features: - Credential store supporting env vars and ~/.mcproxy/credentials.json - browser_type_credential: Type credential into selector by name - browser_keyboard_type_credential: Type credential at focused element - browser_list_credentials: List available credential names (not values) - browser_set_credential/browser_delete_credential: Manage credentials - Automatic response scrubbing: Any credential values in browser responses are replaced with [CREDENTIAL:name] placeholders Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 50 +++++ mcp-server/src/credential-store.ts | 272 +++++++++++++++++++++++++++ mcp-server/src/session-manager.ts | 33 +++- mcp-server/src/tools/index.ts | 288 +++++++++++++++++++++++++++++ 4 files changed, 642 insertions(+), 1 deletion(-) create mode 100644 mcp-server/src/credential-store.ts diff --git a/CLAUDE.md b/CLAUDE.md index f8079a7..7b7c41e 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,55 @@ 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_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) ## 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..f8e34ed 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,291 @@ 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_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 + ), + }, + ], + }; + } + } + ); } From afd6aab3a37d6f529f96e75a2a74f0ded7a3f7ae Mon Sep 17 00:00:00 2001 From: Shawn Date: Mon, 12 Jan 2026 11:21:33 -0500 Subject: [PATCH 2/2] Add browser_has_credential tool and login flow docs Addresses PR feedback: - Add browser_has_credential(name) for checking if a credential exists - Add login flow example showing the cookies + credentials pattern - Add guidance table for cookie/credential combination strategies Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 59 +++++++++++++++++++++++++++++++++++ mcp-server/src/tools/index.ts | 33 ++++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 7b7c41e..5b71580 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -167,6 +167,7 @@ Create `~/.mcproxy/credentials.json`: ### 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) @@ -179,6 +180,64 @@ Create `~/.mcproxy/credentials.json`: 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 1. **Stealth First**: All browser automation uses stealth plugin and anti-detection measures diff --git a/mcp-server/src/tools/index.ts b/mcp-server/src/tools/index.ts index f8e34ed..3836c73 100644 --- a/mcp-server/src/tools/index.ts +++ b/mcp-server/src/tools/index.ts @@ -935,6 +935,39 @@ export function registerTools(server: McpServer, sessionManager: SessionManager) } ); + 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', {