diff --git a/CHANGELOG.md b/CHANGELOG.md index b45af41..135a2a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `connect` command now auto-generates session name when `@session` is omitted (e.g., `mcpc connect mcp.apify.com` creates `@apify`). If a session for the same server already exists with matching auth settings, it is reused instead of creating a duplicate. - `--max-chars ` global option to truncate large tool/prompt/resource output - `tools-call --help` shows tool parameter schema (shortcut for `tools-get`) - "Did you mean?" suggestions for unknown commands, including reversed names (e.g., `list-tools` → `tools-list`) diff --git a/README.md b/README.md index 46cf367..500a70a 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,8 @@ Usage: mcpc [<@session>] [] [options] Universal command-line client for the Model Context Protocol (MCP). Commands: - connect <@session> Connect to an MCP server and start a new named @session + connect [@session] Connect to an MCP server and start a named @session (name + auto-generated if omitted) close <@session> Close a session restart <@session> Restart a session (losing all state) shell <@session> Open interactive shell for a session @@ -299,7 +300,7 @@ By default, `grep` searches only tools. Use `--resources` or `--prompts` to sear (combine with `--tools` to include tools too). Sessions that are crashed or unavailable are shown with their status rather than silently skipped. -The `grep` command is useful for **dynamic tool discovery**, +The `grep` command is useful for **dynamic tool discovery**, also called [Tool search tool](https://www.anthropic.com/engineering/advanced-tool-use) by Anthropic or [Dynamic context discovery](https://cursor.com/blog/dynamic-context-discovery) by Cursor. Rather than loading all tools into AI agent's context, the agent can use `grep` to discover the right tool @@ -358,9 +359,9 @@ Still, sessions can fail due to network disconnects, bridge process crash, or se **Session states:** -| State | Meaning | -| --------------------- | -------------------------------------------------------------------------------------------------- | -| 🟢**`live`** | Bridge process running and server responding | +| State | Meaning | +| -------------------- | -------------------------------------------------------------------------------------------------- | +| 🟢**`live`** | Bridge process running and server responding | | 🟡**`connecting`** | Initial bridge startup in progress (`mcpc connect`) | | 🟡**`reconnecting`** | Bridge crashed or lost auth; auto-reconnecting in the background | | 🟡**`disconnected`** | Bridge process running but server unreachable; auto-recovers when server responds | @@ -786,10 +787,10 @@ mcpc x402 sign --amount 1.00 --expiry 3600 --json **Options:** -| Option | Description | -| ------------------- | ---------------------------------------------------------------- | -| `--amount ` | Override the payment amount in USD (e.g. `0.50` for $0.50) | -| `--expiry `| Override the payment expiry in seconds from now (e.g. `3600`) | +| Option | Description | +| -------------------- | ------------------------------------------------------------- | +| `--amount ` | Override the payment amount in USD (e.g. `0.50` for $0.50) | +| `--expiry ` | Override the payment expiry in seconds from now (e.g. `3600`) | The command outputs the signed `PAYMENT-SIGNATURE` header value and an MCP config snippet that can be used directly with other MCP clients. @@ -860,7 +861,7 @@ The bridge process manages the full MCP session lifecycle: | 🔔 [**Notifications**](#list-change-notifications) | ✅ Supported | | 📄 [**Pagination**](#pagination) | ✅ Supported | | 🏓 [**Ping**](#ping) | ✅ Supported | -| ⏳ [**Async tasks**](#async-tasks) | ✅ Supported | +| ⏳ [**Async tasks**](#async-tasks) | ✅ Supported | | 📁 **Roots** | 🚧 Planned | | ❓ **Elicitation** | 🚧 Planned | | 🔤 **Completion** | 🚧 Planned | @@ -1266,19 +1267,19 @@ See [CONTRIBUTING](./CONTRIBUTING.md) for development setup, architecture overvi | Tool | Lang | Stars | Contrib / Commits | Active | Tools | Resources | Prompts | Tasks | Code mode | Sessions | OAuth | Stdio | HTTP | Tool search | x402 | LLM | -| ----------------------------------------------------------------------- | ------ | ----: | -----------------: | ------ | ----- | --------- | ------- | ----- | --------- | -------- | ----- | ----- | ---- | ----------- | ---- | --- | -| **[apify/mcpc](https://github.com/apify/mcpc)** | TS | ~420 | 7 / ~510 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | -| [steipete/mcporter](https://github.com/steipete/mcporter) | TS | ~3.5k | 24 / ~570 | ✅ | ✅ | — | — | — | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | — | -| [IBM/mcp-cli](https://github.com/IBM/mcp-cli) | Python | ~1.9k | 22 / ~790 | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | ✅ | -| [knowsuchagency/mcp2cli](https://github.com/knowsuchagency/mcp2cli) | Python | ~1.8k | 5 / ~76 | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | -| [f/mcptools](https://github.com/f/mcptools) | Go | ~1.5k | 15 / ~170 | ⚠️ | ✅ | ✅ | ✅ | — | ✅ | — | — | ✅ | ✅ | — | — | — | -| [philschmid/mcp-cli](https://github.com/philschmid/mcp-cli) | TS | ~1.1k | 2 / ~30 | ✅ | ✅ | — | — | — | ✅ | ✅ | — | ✅ | ✅ | ✅ | — | — | -| [adhikasp/mcp-client-cli](https://github.com/adhikasp/mcp-client-cli) | Python | ~670 | 6 / ~110 | ⚠️ | ✅ | ✅ | ✅ | — | — | — | — | ✅ | — | — | — | ✅ | -| [thellimist/clihub](https://github.com/thellimist/clihub) | Go | ~640 | 1 / ~60 | ✅ | ✅ | — | — | — | — | — | ✅ | ✅ | ✅ | ✅ | — | — | -| [wong2/mcp-cli](https://github.com/wong2/mcp-cli) | JS | ~430 | 4 / ~63 | ⚠️ | ✅ | ✅ | ✅ | — | — | — | ✅ | — | ✅ | — | — | — | -| [mcpshim/mcpshim](https://github.com/mcpshim/mcpshim) | Go | ~54 | 1 / ~13 | ✅ | ✅ | — | — | — | ✅ | ✅ | ✅ | — | ✅ | ✅ | — | — | -| [evantahler/mcpx](https://github.com/evantahler/mcpx) | TS | ~28 | 1 / ~64 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | ✅ | ✅ | — | — | -| [EstebanForge/mcp-cli-ent](https://github.com/EstebanForge/mcp-cli-ent) | Go | ~15 | ~2 / ~46 | ✅ | ✅ | — | — | — | ✅ | ✅ | — | ✅ | ✅ | ✅ | — | — | +| ----------------------------------------------------------------------- | ------ | ----: | ----------------: | ------ | ----- | --------- | ------- | ----- | --------- | -------- | ----- | ----- | ---- | ----------- | ---- | --- | +| **[apify/mcpc](https://github.com/apify/mcpc)** | TS | ~420 | 7 / ~510 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | +| [steipete/mcporter](https://github.com/steipete/mcporter) | TS | ~3.5k | 24 / ~570 | ✅ | ✅ | — | — | — | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | — | +| [IBM/mcp-cli](https://github.com/IBM/mcp-cli) | Python | ~1.9k | 22 / ~790 | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | ✅ | +| [knowsuchagency/mcp2cli](https://github.com/knowsuchagency/mcp2cli) | Python | ~1.8k | 5 / ~76 | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | +| [f/mcptools](https://github.com/f/mcptools) | Go | ~1.5k | 15 / ~170 | ⚠️ | ✅ | ✅ | ✅ | — | ✅ | — | — | ✅ | ✅ | — | — | — | +| [philschmid/mcp-cli](https://github.com/philschmid/mcp-cli) | TS | ~1.1k | 2 / ~30 | ✅ | ✅ | — | — | — | ✅ | ✅ | — | ✅ | ✅ | ✅ | — | — | +| [adhikasp/mcp-client-cli](https://github.com/adhikasp/mcp-client-cli) | Python | ~670 | 6 / ~110 | ⚠️ | ✅ | ✅ | ✅ | — | — | — | — | ✅ | — | — | — | ✅ | +| [thellimist/clihub](https://github.com/thellimist/clihub) | Go | ~640 | 1 / ~60 | ✅ | ✅ | — | — | — | — | — | ✅ | ✅ | ✅ | ✅ | — | — | +| [wong2/mcp-cli](https://github.com/wong2/mcp-cli) | JS | ~430 | 4 / ~63 | ⚠️ | ✅ | ✅ | ✅ | — | — | — | ✅ | — | ✅ | — | — | — | +| [mcpshim/mcpshim](https://github.com/mcpshim/mcpshim) | Go | ~54 | 1 / ~13 | ✅ | ✅ | — | — | — | ✅ | ✅ | ✅ | — | ✅ | ✅ | — | — | +| [evantahler/mcpx](https://github.com/evantahler/mcpx) | TS | ~28 | 1 / ~64 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | ✅ | ✅ | — | — | +| [EstebanForge/mcp-cli-ent](https://github.com/EstebanForge/mcp-cli-ent) | Go | ~15 | ~2 / ~46 | ✅ | ✅ | — | — | — | ✅ | ✅ | — | ✅ | ✅ | ✅ | — | — | **Legend:** ✅ = supported, ⚠️ = stale (no commits in 3+ months), **Contrib / Commits** = contributors / total commits, **Tasks** = [async tasks](https://modelcontextprotocol.io/specification/latest/server/utilities/tasks), **x402** = [x402 payment protocol](https://www.x402.org/) support, **LLM** = requires/uses an LLM. diff --git a/src/cli/commands/sessions.ts b/src/cli/commands/sessions.ts index 3b6b989..8a427dc 100644 --- a/src/cli/commands/sessions.ts +++ b/src/cli/commands/sessions.ts @@ -6,6 +6,8 @@ import { createServer } from 'net'; import { OutputMode, isValidSessionName, + generateSessionName, + normalizeServerUrl, validateProfileName, isProcessAlive, getServerHost, @@ -30,6 +32,7 @@ import { updateSession, consolidateSessions, getSession, + loadSessions, } from '../../lib/sessions.js'; import { startBridge, @@ -80,6 +83,115 @@ async function checkPortAvailable(host: string, port: number): Promise }); } +/** + * Find an existing session that matches the given server target and authentication settings. + * Used when auto-generating session names to reuse existing sessions instead of creating duplicates. + * + * @returns The matching session name (with @ prefix), or undefined if no match found + */ +async function findMatchingSession( + parsed: { type: 'url'; url: string } | { type: 'config'; file: string; entry: string }, + options: { profile?: string; headers?: string[]; noProfile?: boolean } +): Promise { + const storage = await loadSessions(); + const sessions = Object.values(storage.sessions); + + if (sessions.length === 0) return undefined; + + // Determine the effective profile name for comparison + const effectiveProfile = options.noProfile ? undefined : (options.profile ?? 'default'); + + for (const session of sessions) { + if (!session.server) continue; + + // Match server target + if (parsed.type === 'url') { + if (!session.server.url) continue; + // Compare normalized URLs + try { + const existingUrl = normalizeServerUrl(session.server.url); + const newUrl = normalizeServerUrl(parsed.url); + if (existingUrl !== newUrl) continue; + } catch { + continue; + } + } else { + // Config entry: match by command (stdio transport) + // Config entries produce stdio configs with command/args, so we can't easily + // compare them. Instead, just compare generated session names for config targets. + // This is handled by the caller (resolveSessionName) via name-based dedup. + continue; + } + + // Match profile + const sessionProfile = session.profileName ?? 'default'; + if (effectiveProfile !== sessionProfile) continue; + + // Match header keys (values are redacted, so we only compare key sets) + const existingHeaderKeys = Object.keys(session.server.headers || {}).sort(); + const newHeaderKeys = (options.headers || []) + .map((h) => h.split(':')[0]?.trim() || '') + .filter(Boolean) + .sort(); + if (existingHeaderKeys.join(',') !== newHeaderKeys.join(',')) continue; + + // Found a match + return session.name; + } + + return undefined; +} + +/** + * Resolve the session name when @session is omitted from `mcpc connect`. + * Finds an existing matching session or generates a new unique name. + * + * @returns Session name with @ prefix + */ +export async function resolveSessionName( + parsed: { type: 'url'; url: string } | { type: 'config'; file: string; entry: string }, + options: { + outputMode: OutputMode; + profile?: string; + headers?: string[]; + noProfile?: boolean; + } +): Promise { + // First, check if an existing session matches this server + auth settings + const existingName = await findMatchingSession(parsed, options); + if (existingName) { + return existingName; + } + + // Generate a new session name + const candidateName = generateSessionName(parsed); + + // Check if the candidate name is already taken by a different server + const storage = await loadSessions(); + if (!(candidateName in storage.sessions)) { + if (options.outputMode === 'human') { + console.log(chalk.cyan(`Using session name: ${candidateName}`)); + } + return candidateName; + } + + // Name is taken - try suffixed variants + for (let i = 2; i <= 99; i++) { + const suffixed = `${candidateName}-${i}`; + if (isValidSessionName(suffixed) && !(suffixed in storage.sessions)) { + if (options.outputMode === 'human') { + console.log(chalk.cyan(`Using session name: ${suffixed}`)); + } + return suffixed; + } + } + + throw new ClientError( + `Cannot auto-generate session name: too many sessions for this server.\n` + + `Specify a name explicitly: mcpc connect ${parsed.type === 'url' ? parsed.url : `${parsed.file}:${parsed.entry}`} @my-session` + ); +} + /** * Creates a new session, starts a bridge process, and instructs it to connect an MCP server. * If session already exists with crashed bridge, reconnects it automatically diff --git a/src/cli/index.ts b/src/cli/index.ts index bc25edf..d3690ca 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -432,11 +432,13 @@ Run "mcpc" without arguments to show active sessions and OAuth profiles. Full docs: ${docsUrl}` ); - // connect command: mcpc connect @ + // connect command: mcpc connect [@] program .command('connect [server] [@session]') - .usage(' <@session>') - .description('Connect to an MCP server and start a new named @session') + .usage(' [@session]') + .description( + 'Connect to an MCP server and start a named @session (name auto-generated if omitted)' + ) .option('-H, --header
', 'HTTP header (can be repeated)') .option('--profile ', 'OAuth profile to use ("default" if skipped)') .option('--no-profile', 'Skip OAuth profile (connect anonymously)') @@ -449,6 +451,13 @@ Full docs: ${docsUrl}` ${chalk.bold('Server formats:')} mcp.apify.com Remote HTTP server (https:// added automatically) ~/.vscode/mcp.json:puppeteer Config file entry (file:entry) + +${chalk.bold('Session name:')} + If @session is omitted, a name is auto-generated from the server hostname + (e.g. mcp.apify.com → @apify) or config entry name. If a matching session + already exists (same server URL, OAuth profile, and HTTP header names), it + is reused (restarted if not live). Header values are not compared — they + are stored securely in OS keychain. ${jsonHelp('`InitializeResult` extended with `tools` and `_mcpc` metadata', '`{ protocolVersion, capabilities, serverInfo, instructions?, tools?, _mcpc }`', `${SCHEMA_BASE}#initializeresult`)}` ) .action(async (server, sessionName, opts, command) => { @@ -457,11 +466,6 @@ ${jsonHelp('`InitializeResult` extended with `tools` and `_mcpc` metadata', '`{ 'Missing required argument: server\n\nExample: mcpc connect mcp.apify.com @myapp' ); } - if (!sessionName) { - throw new ClientError( - 'Missing required argument: @session\n\nExample: mcpc connect mcp.apify.com @myapp' - ); - } const globalOpts = getOptionsFromCommand(command); const parsed = parseServerArg(server); @@ -479,6 +483,16 @@ ${jsonHelp('`InitializeResult` extended with `tools` and `_mcpc` metadata', '`{ ); } + // Auto-generate session name if not provided + if (!sessionName) { + sessionName = await sessions.resolveSessionName(parsed, { + outputMode: globalOpts.outputMode, + ...(globalOpts.profile && { profile: globalOpts.profile }), + ...(headers && { headers }), + ...(globalOpts.noProfile && { noProfile: globalOpts.noProfile }), + }); + } + if (parsed.type === 'config') { // Config file entry: pass entry name as target with config file path await sessions.connectSession(parsed.entry, sessionName, { diff --git a/src/lib/utils.ts b/src/lib/utils.ts index ab7f979..6e71a8d 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -217,6 +217,79 @@ export function isValidSessionName(name: string): boolean { return /^@[a-zA-Z0-9_-]{1,64}$/.test(name); } +/** Common hostname prefixes to strip when generating session names */ +const COMMON_HOST_PREFIXES = ['mcp.', 'api.', 'www.']; + +/** + * Sanitize a string into a valid session name part (without @ prefix). + * Replaces invalid characters with hyphens, collapses consecutive hyphens, + * and trims leading/trailing hyphens. Truncates to 64 characters. + */ +function sanitizeSessionName(raw: string): string { + return raw + .toLowerCase() + .replace(/[^a-z0-9_-]/g, '-') // replace invalid chars with hyphens + .replace(/-{2,}/g, '-') // collapse consecutive hyphens + .replace(/^-+|-+$/g, '') // trim leading/trailing hyphens + .slice(0, 64); +} + +/** + * Generate a session name from a parsed server argument. + * + * For URL targets: extracts the "brand" part of the hostname. + * - Strips common prefixes (mcp., api., www.) + * - Takes the first remaining label (before the first dot) + * - Appends non-standard port as - + * Examples: mcp.apify.com → apify, mcp.example.co.uk → example, localhost:3000 → localhost-3000 + * + * For config entries: uses the entry name directly (sanitized). + * Example: ~/.vscode/mcp.json:filesystem → filesystem + * + * @returns Session name with @ prefix (e.g., @apify) + */ +export function generateSessionName( + parsed: { type: 'url'; url: string } | { type: 'config'; file: string; entry: string } +): string { + if (parsed.type === 'config') { + const name = sanitizeSessionName(parsed.entry); + return `@${name || 'session'}`; + } + + // URL case: parse and extract hostname + const url = new URL(normalizeServerUrl(parsed.url)); + let hostname = url.hostname.toLowerCase(); + + // For IP addresses, use the full address (dots will be sanitized to hyphens) + const isIpAddress = /^\d{1,3}(\.\d{1,3}){3}$/.test(hostname); + let name: string; + + if (isIpAddress) { + name = hostname; + } else { + // Strip common prefixes + for (const prefix of COMMON_HOST_PREFIXES) { + if (hostname.startsWith(prefix) && hostname.length > prefix.length) { + hostname = hostname.slice(prefix.length); + break; // only strip one prefix + } + } + + // Take the first label (before the first dot) + const labels = hostname.split('.'); + name = labels.length >= 2 ? (labels[0] ?? hostname) : hostname; + } + + // Append non-standard port + const port = url.port; + if (port) { + name += `-${port}`; + } + + const sanitized = sanitizeSessionName(name); + return `@${sanitized || 'session'}`; +} + /** * Validate if a string is a valid profile name. * Profile names must be alphanumeric with hyphens/underscores, 1-64 chars (no @ prefix) diff --git a/test/unit/lib/utils.test.ts b/test/unit/lib/utils.test.ts index 227634c..5009f80 100644 --- a/test/unit/lib/utils.test.ts +++ b/test/unit/lib/utils.test.ts @@ -15,6 +15,7 @@ import { normalizeServerUrl, getServerHost, isValidSessionName, + generateSessionName, isValidProfileName, validateProfileName, isValidResourceUri, @@ -288,6 +289,115 @@ describe('isValidSessionName', () => { }); }); +describe('generateSessionName', () => { + describe('URL targets', () => { + it('should extract brand from mcp.*.com hostnames', () => { + expect(generateSessionName({ type: 'url', url: 'mcp.apify.com' })).toBe('@apify'); + expect(generateSessionName({ type: 'url', url: 'mcp.example.com' })).toBe('@example'); + }); + + it('should extract brand from api.*.com hostnames', () => { + expect(generateSessionName({ type: 'url', url: 'api.example.com' })).toBe('@example'); + }); + + it('should extract brand from www.*.com hostnames', () => { + expect(generateSessionName({ type: 'url', url: 'www.example.com' })).toBe('@example'); + }); + + it('should handle multi-part TLDs (co.uk, etc.)', () => { + expect(generateSessionName({ type: 'url', url: 'mcp.example.co.uk' })).toBe('@example'); + }); + + it('should handle deep subdomains by stripping only one prefix', () => { + expect(generateSessionName({ type: 'url', url: 'api.deep-research.anthropic.com' })).toBe( + '@deep-research' + ); + }); + + it('should use the hostname directly when no common prefix', () => { + expect(generateSessionName({ type: 'url', url: 'simple.com' })).toBe('@simple'); + }); + + it('should handle single-label hostnames', () => { + expect(generateSessionName({ type: 'url', url: 'localhost' })).toBe('@localhost'); + }); + + it('should append non-standard port', () => { + expect(generateSessionName({ type: 'url', url: 'localhost:3000' })).toBe('@localhost-3000'); + expect(generateSessionName({ type: 'url', url: '127.0.0.1:8080' })).toBe('@127-0-0-1-8080'); + }); + + it('should not append standard ports', () => { + expect(generateSessionName({ type: 'url', url: 'https://example.com:443' })).toBe('@example'); + expect(generateSessionName({ type: 'url', url: 'http://localhost:80' })).toBe('@localhost'); + }); + + it('should handle IP addresses by replacing dots with hyphens', () => { + expect(generateSessionName({ type: 'url', url: '127.0.0.1' })).toBe('@127-0-0-1'); + expect(generateSessionName({ type: 'url', url: '192.168.1.100' })).toBe('@192-168-1-100'); + }); + + it('should handle full URLs with scheme', () => { + expect(generateSessionName({ type: 'url', url: 'https://mcp.apify.com' })).toBe('@apify'); + expect(generateSessionName({ type: 'url', url: 'http://localhost:3000' })).toBe( + '@localhost-3000' + ); + }); + + it('should lowercase the result', () => { + expect(generateSessionName({ type: 'url', url: 'MCP.APIFY.COM' })).toBe('@apify'); + }); + + it('should produce valid session names', () => { + const urls = [ + 'mcp.apify.com', + 'mcp.example.co.uk', + 'localhost:3000', + '127.0.0.1:8080', + 'api.deep-research.anthropic.com', + 'simple.com', + ]; + for (const url of urls) { + const name = generateSessionName({ type: 'url', url }); + expect(isValidSessionName(name)).toBe(true); + } + }); + }); + + describe('config entry targets', () => { + it('should use the entry name directly', () => { + expect( + generateSessionName({ type: 'config', file: '~/.vscode/mcp.json', entry: 'filesystem' }) + ).toBe('@filesystem'); + }); + + it('should sanitize special characters', () => { + expect( + generateSessionName({ type: 'config', file: '~/.vscode/mcp.json', entry: 'my server' }) + ).toBe('@my-server'); + expect( + generateSessionName({ + type: 'config', + file: '~/.vscode/mcp.json', + entry: 'my.server.name', + }) + ).toBe('@my-server-name'); + }); + + it('should produce valid session names', () => { + const entries = ['filesystem', 'my-server', 'puppeteer', 'test_server']; + for (const entry of entries) { + const name = generateSessionName({ + type: 'config', + file: '~/.vscode/mcp.json', + entry, + }); + expect(isValidSessionName(name)).toBe(true); + } + }); + }); +}); + describe('isValidProfileName', () => { it('should return true for valid profile names', () => { expect(isValidProfileName(DEFAULT_AUTH_PROFILE)).toBe(true);