diff --git a/.gitattributes b/.gitattributes index d0c0c4c..2fc9878 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,3 @@ * text eol=lf *.png binary +dist-bun/*.tar.gz binary diff --git a/README.md b/README.md index be82bb6..e82264e 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ MCPorter helps you lean into the "code execution" workflows highlighted in Anthr - **One-command CLI generation.** `mcporter generate-cli` turns any MCP server definition into a ready-to-run CLI, with optional bundling/compilation and metadata for easy regeneration. - **Typed tool clients.** `mcporter emit-ts` emits `.d.ts` interfaces or ready-to-run client wrappers so agents/tests can call MCP servers with strong TypeScript types without hand-writing plumbing. - **Friendly composable API.** `createServerProxy()` exposes tools as ergonomic camelCase methods, automatically applies JSON-schema defaults, validates required arguments, and hands back a `CallResult` with `.text()`, `.markdown()`, `.json()`, `.images()`, and `.content()` helpers. +- **On-demand heavy MCPs.** Keep large schema payloads such as `chrome-devtools` out of your default config until you need them, then toggle them with `mcporter heavy list|activate|deactivate`. - **OAuth and stdio ergonomics.** Built-in OAuth caching, log tailing, and stdio wrappers let you work with HTTP, SSE, and stdio transports from the same interface. - **Ad-hoc connections.** Point the CLI at *any* MCP endpoint (HTTP or stdio) without touching config, then persist it later if you want. Hosted MCPs that expect a browser login (Supabase, Vercel, etc.) are auto-detected—just run `mcporter auth ` and the CLI promotes the definition to OAuth on the fly. See [docs/adhoc.md](docs/adhoc.md). @@ -184,6 +185,18 @@ npx mcporter call --stdio "bun run ./local-server.ts" --name local-tools - The daemon only manages named servers that come from your config/imports. Ad-hoc STDIO/HTTP targets invoked via `--stdio …`, `--http-url …`, or inline function-call syntax remain per-process today; persist them into `config/mcporter.json` (or use `--persist`) if you need them to participate in the shared daemon. - Troubleshooting? Run `mcporter daemon start --log` (or `--log-file /tmp/daemon.log`) to tee stdout/stderr into a file, and add `--log-servers chrome-devtools` when you only want call traces for a specific MCP. Per-server configs can also set `"logging": { "daemon": { "enabled": true } }` to force detailed logging for that entry. +### Load heavy MCPs on demand + +If a server ships an unusually large schema payload, keep it out of your default config and enable it only when needed: + +```bash +npx mcporter heavy list +npx mcporter heavy activate chrome-devtools +npx mcporter heavy deactivate chrome-devtools +``` + +Heavy definitions live next to your resolved `mcporter.json` under `heavy/available/`, and activation merges the selected definition into the main config while tracking its status under `heavy/active/`. If a heavy definition would overwrite an existing `mcpServers` entry with different settings, activation aborts instead of clobbering your config. + ## Friendlier Tool Calls diff --git a/src/cli.ts b/src/cli.ts index ae623fa..9df013b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,6 +8,7 @@ import { handleDaemonCli } from './cli/daemon-command.js'; import { handleEmitTs } from './cli/emit-ts-command.js'; import { CliUsageError } from './cli/errors.js'; import { handleGenerateCli } from './cli/generate-cli-runner.js'; +import { handleHeavyCli } from './cli/heavy-command.js'; import { consumeHelpTokens, isHelpToken, isVersionToken, printHelp, printVersion } from './cli/help-output.js'; import { handleInspectCli } from './cli/inspect-cli-command.js'; import { handleList, printListHelp } from './cli/list-command.js'; @@ -23,6 +24,7 @@ export { handleAuth, printAuthHelp } from './cli/auth-command.js'; export { parseCallArguments } from './cli/call-arguments.js'; export { handleCall } from './cli/call-command.js'; export { handleGenerateCli } from './cli/generate-cli-runner.js'; +export { handleHeavyCli } from './cli/heavy-command.js'; export { handleInspectCli } from './cli/inspect-cli-command.js'; export { extractListFlags, handleList } from './cli/list-command.js'; export { resolveCallTimeout } from './cli/timeouts.js'; @@ -100,6 +102,11 @@ export async function runCli(argv: string[]): Promise { return; } + if (command === 'heavy') { + await handleHeavyCli(args, { configPath, rootDir: rootOverride }); + return; + } + if (command === 'emit-ts') { const runtime = await createRuntime(runtimeOptionsWithPath); try { diff --git a/src/cli/heavy-command.ts b/src/cli/heavy-command.ts new file mode 100644 index 0000000..d35c29b --- /dev/null +++ b/src/cli/heavy-command.ts @@ -0,0 +1,378 @@ +/** + * Heavy MCP management commands. + * + * Some MCP servers (like chrome-devtools) have large tool schemas that consume + * significant context tokens. The "heavy" system allows on-demand loading of + * these servers to save context when they're not needed. + * + * Usage: + * mcporter heavy list - List available and active heavy MCPs + * mcporter heavy activate - Activate a heavy MCP + * mcporter heavy deactivate - Deactivate a heavy MCP + */ + +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { isDeepStrictEqual } from 'node:util'; +import { z } from 'zod'; +import { loadRawConfig, writeRawConfig } from '../config.js'; +import { + assertValidHeavyMcpName, + type HeavyMcpDefinition, + HeavyMcpServersSchema, + listHeavyMcpDefinitions, + readHeavyMcpDefinition, +} from '../heavy/definition.js'; +import { type HeavyPaths, resolveHeavyPaths } from '../heavy/paths.js'; +import { logWarn } from './logger-context.js'; + +interface HeavyCliOptions { + configPath?: string; + rootDir?: string; +} + +const ActiveHeavyMcpMarkerSchema = z.object({ + activated: z.string(), + serverNames: z.array(z.string()).min(1), + mcpServers: HeavyMcpServersSchema.optional(), +}); + +type ActiveHeavyMcpMarker = z.infer; + +export async function handleHeavyCli(args: string[], options: HeavyCliOptions): Promise { + const subcommand = args.shift(); + if (!subcommand || subcommand === 'help' || subcommand === '--help' || subcommand === '-h') { + process.exitCode = 0; + printHeavyHelp(); + return; + } + + const { path: configPath } = await loadRawConfig(options); + const paths = resolveHeavyPaths(configPath); + + if (subcommand === 'list') { + await handleHeavyList(paths, options); + return; + } + if (subcommand === 'activate') { + await handleHeavyActivate(args, paths, options); + return; + } + if (subcommand === 'deactivate') { + await handleHeavyDeactivate(args, paths, options); + return; + } + + throw new Error(`Unknown heavy subcommand '${subcommand}'. Run 'mcporter heavy --help'.`); +} + +function printHeavyHelp(): void { + console.error(`Usage: mcporter heavy + +Manage "heavy" MCP servers that are loaded on-demand to save context. + +Heavy MCPs are servers with large tool schemas (e.g., chrome-devtools) that +consume significant context tokens. By default, they are not loaded. Use this +command to activate them when needed and deactivate when done. + +Commands: + list List available and currently active heavy MCPs. + activate Activate a heavy MCP (adds to main config). + deactivate Deactivate a heavy MCP (removes from main config). + +Directory structure: + ~/.mcporter/ + ├── mcporter.json # Main config (without heavy MCPs by default) + └── heavy/ + ├── available/ # Heavy MCP definitions + │ └── chrome-devtools.json + └── active/ # Tracks active heavy MCPs (marker files) + └── chrome-devtools.json + +Examples: + mcporter heavy list + mcporter heavy activate chrome-devtools + mcporter heavy deactivate chrome-devtools`); +} + +async function handleHeavyList(paths: HeavyPaths, options: HeavyCliOptions): Promise { + const available = await listHeavyMcpDefinitions(paths.availableDir); + const active = await listActiveHeavyMcps(paths, options); + + console.log('=== Available Heavy MCPs ==='); + if (available.length === 0) { + console.log('(none)'); + console.log(''); + console.log(`Place JSON files in ${paths.availableDir}/ to add heavy MCPs.`); + } else { + for (const name of available) { + const isActive = active.includes(name); + const status = isActive ? ' [active]' : ''; + console.log(` ${name}${status}`); + } + } + + console.log(''); + console.log('=== Active Heavy MCPs ==='); + if (active.length === 0) { + console.log('(none)'); + } else { + for (const name of active) { + console.log(` ${name}`); + } + } +} + +async function handleHeavyActivate(args: string[], paths: HeavyPaths, options: HeavyCliOptions): Promise { + const name = args.shift(); + if (!name) { + throw new Error('Usage: mcporter heavy activate '); + } + assertValidHeavyMcpName(name); + + // Check if the heavy MCP definition exists + const definition = await readHeavyMcpDefinition(paths.availableDir, name); + if (!definition) { + throw new Error(`Heavy MCP '${name}' not found in ${paths.availableDir}`); + } + + // Load current config + const { config, path: configPath } = await loadRawConfig(options); + const activePath = path.join(paths.activeDir, `${name}.json`); + const marker = await readActiveMarker(activePath); + const markerDefinition = marker ? getHeavyDefinitionFromMarker(marker) : null; + + const activeDefinition = findActiveHeavyDefinition( + config.mcpServers, + [definition, markerDefinition], + marker?.serverNames + ); + if (activeDefinition) { + await fsPromises.mkdir(paths.activeDir, { recursive: true }); + await writeActiveMarker(activePath, activeDefinition); + console.log(`Heavy MCP '${name}' is already active.`); + return; + } + + const conflictingServerNames = findConflictingHeavyServerNames(config.mcpServers, definition); + if (conflictingServerNames.length > 0) { + throw new Error( + `Cannot activate heavy MCP '${name}' because these server entries already exist with different settings: ${conflictingServerNames + .map((serverName) => `'${serverName}'`) + .join(', ')}.` + ); + } + + // Merge the heavy MCP servers into main config + if (!config.mcpServers) { + config.mcpServers = {}; + } + for (const [serverName, serverDef] of Object.entries(definition.mcpServers)) { + config.mcpServers[serverName] = serverDef; + } + + // Write back config + await writeRawConfig(configPath, config); + + // Refresh active marker metadata + await fsPromises.mkdir(paths.activeDir, { recursive: true }); + await writeActiveMarker(activePath, definition); + + console.log(`Activated: ${name}`); +} + +async function handleHeavyDeactivate(args: string[], paths: HeavyPaths, options: HeavyCliOptions): Promise { + const name = args.shift(); + if (!name) { + throw new Error('Usage: mcporter heavy deactivate '); + } + assertValidHeavyMcpName(name); + + // Load current config + const { config, path: configPath } = await loadRawConfig(options); + const activePath = path.join(paths.activeDir, `${name}.json`); + + const marker = await readActiveMarker(activePath); + let serverNames: string[]; + if (marker) { + const markerDefinition = getHeavyDefinitionFromMarker(marker); + let currentDefinition: HeavyMcpDefinition | null = null; + try { + currentDefinition = await readHeavyMcpDefinition(paths.availableDir, name); + } catch {} + + const activeDefinition = findActiveHeavyDefinition( + config.mcpServers, + [currentDefinition, markerDefinition], + marker.serverNames + ); + if (!activeDefinition) { + console.log(`Heavy MCP '${name}' is not active.`); + return; + } + serverNames = Object.keys(activeDefinition.mcpServers); + } else { + let definition: HeavyMcpDefinition | null; + try { + definition = await readHeavyMcpDefinition(paths.availableDir, name); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `Cannot deactivate heavy MCP '${name}' because its marker metadata is missing and its definition is invalid: ${message}` + ); + } + + if (!definition || !isHeavyMcpDefinitionActiveInConfig(config.mcpServers, definition)) { + console.log(`Heavy MCP '${name}' is not active.`); + return; + } + + serverNames = Object.keys(definition.mcpServers); + } + + // Remove the server(s) from config + for (const serverName of serverNames) { + delete config.mcpServers?.[serverName]; + } + + // Write back config + await writeRawConfig(configPath, config); + + // Remove active marker + await fsPromises.unlink(activePath).catch(() => {}); + + console.log(`Deactivated: ${name}`); +} + +async function listActiveHeavyMcps(paths: HeavyPaths, options: HeavyCliOptions): Promise { + const { config } = await loadRawConfig(options); + const active = new Set(); + const definitions = new Map(); + const available = await listHeavyMcpDefinitions(paths.availableDir); + await Promise.all( + available.map(async (name) => { + const definition = await readHeavyDefinitionForActiveDetection(paths.availableDir, name); + definitions.set(name, definition); + if (definition && isHeavyMcpDefinitionActiveInConfig(config.mcpServers, definition)) { + active.add(name); + } + }) + ); + + const marked = await listHeavyMcpDefinitions(paths.activeDir); + await Promise.all( + marked.map(async (name) => { + let definition: HeavyMcpDefinition | null; + if (definitions.has(name)) { + definition = definitions.get(name) ?? null; + } else { + definition = await readHeavyDefinitionForActiveDetection(paths.availableDir, name); + definitions.set(name, definition); + } + + const marker = await readActiveMarker(path.join(paths.activeDir, `${name}.json`)); + const activeDefinition = findActiveHeavyDefinition( + config.mcpServers, + [definition, marker ? getHeavyDefinitionFromMarker(marker) : null], + marker?.serverNames + ); + if (activeDefinition) { + active.add(name); + } + }) + ); + + return [...active]; +} + +function isHeavyMcpDefinitionActiveInConfig( + configuredServers: Record | undefined, + definition: HeavyMcpDefinition, + extraServerNamesMustBeAbsent: string[] = [] +): boolean { + if (extraServerNamesMustBeAbsent.some((serverName) => configuredServers?.[serverName] !== undefined)) { + return false; + } + + return Object.entries(definition.mcpServers).every(([serverName, definitionEntry]) => + isDeepStrictEqual(configuredServers?.[serverName], definitionEntry) + ); +} + +function findConflictingHeavyServerNames( + configuredServers: Record | undefined, + definition: HeavyMcpDefinition +): string[] { + return Object.entries(definition.mcpServers) + .filter(([serverName, definitionEntry]) => { + const configuredEntry = configuredServers?.[serverName]; + return configuredEntry !== undefined && !isDeepStrictEqual(configuredEntry, definitionEntry); + }) + .map(([serverName]) => serverName); +} + +function findActiveHeavyDefinition( + configuredServers: Record | undefined, + definitions: Array, + relatedServerNames: string[] = [] +): HeavyMcpDefinition | null { + for (const definition of definitions) { + if (!definition) { + continue; + } + const extraRelatedServerNames = relatedServerNames.filter((serverName) => !(serverName in definition.mcpServers)); + if (isHeavyMcpDefinitionActiveInConfig(configuredServers, definition, extraRelatedServerNames)) { + return definition; + } + } + return null; +} + +function getHeavyDefinitionFromMarker(marker: ActiveHeavyMcpMarker): HeavyMcpDefinition | null { + return marker.mcpServers ? { mcpServers: marker.mcpServers } : null; +} + +async function readHeavyDefinitionForActiveDetection( + availableDir: string, + name: string +): Promise>> { + try { + return await readHeavyMcpDefinition(availableDir, name); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logWarn(`Skipping invalid heavy MCP definition '${name}': ${message}`); + return null; + } +} + +async function readActiveMarker(activePath: string): Promise { + try { + const buffer = await fsPromises.readFile(activePath, 'utf8'); + const parsed = JSON.parse(buffer); + const validation = ActiveHeavyMcpMarkerSchema.safeParse(parsed); + if (!validation.success) { + return null; + } + return validation.data; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } + return null; + } +} + +async function writeActiveMarker(activePath: string, definition: HeavyMcpDefinition): Promise { + const marker: ActiveHeavyMcpMarker = { + activated: new Date().toISOString(), + serverNames: Object.keys(definition.mcpServers), + mcpServers: definition.mcpServers, + }; + + await fsPromises.unlink(activePath).catch((error) => { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + }); + await fsPromises.writeFile(activePath, `${JSON.stringify(marker, null, 2)}\n`, 'utf8'); +} diff --git a/src/cli/help-output.ts b/src/cli/help-output.ts index ce8889d..d66af63 100644 --- a/src/cli/help-output.ts +++ b/src/cli/help-output.ts @@ -92,6 +92,11 @@ function buildCommandSections(colorize: boolean): string[] { summary: 'Inspect or edit config files (list, get, add, remove, import, login, logout)', usage: 'mcporter config [options]', }, + { + name: 'heavy', + summary: 'Manage large MCP definitions with on-demand activation', + usage: 'mcporter heavy ', + }, ], }, { diff --git a/src/heavy/definition.ts b/src/heavy/definition.ts new file mode 100644 index 0000000..e385483 --- /dev/null +++ b/src/heavy/definition.ts @@ -0,0 +1,131 @@ +/** + * Heavy MCP definition file handling. + * + * A heavy MCP definition file is a JSON file containing MCP server configurations + * that are stored separately from the main mcporter.json to save context tokens. + * + * Example (chrome-devtools.json): + * { + * "mcpServers": { + * "chrome-devtools": { + * "command": "npx", + * "args": ["-y", "chrome-devtools-mcp@latest"] + * } + * } + * } + */ + +import fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import { z } from 'zod'; +import type { RawEntry } from '../config-schema.js'; +import { RawEntrySchema } from '../config-schema.js'; + +export interface HeavyMcpDefinition { + mcpServers: Record; +} + +export const HeavyMcpServersSchema = z + .record(z.string(), RawEntrySchema) + .refine((mcpServers) => Object.keys(mcpServers).length > 0, 'must contain at least one server'); + +const HeavyMcpDefinitionSchema = z.object({ + mcpServers: HeavyMcpServersSchema, +}); + +export function assertValidHeavyMcpName(name: string): void { + const isSafeBasename = + name.length > 0 && + name !== '.' && + name !== '..' && + name === path.posix.basename(name) && + name === path.win32.basename(name) && + !name.includes('\0'); + + if (!isSafeBasename) { + throw new Error(`Invalid heavy MCP name '${name}'. Use a simple basename without path separators.`); + } +} + +/** + * Read a heavy MCP definition file. + * + * @param availableDir - Directory containing available heavy MCPs + * @param name - Name of the heavy MCP (without .json extension) + * @returns The definition or null if not found + */ +export async function readHeavyMcpDefinition(availableDir: string, name: string): Promise { + assertValidHeavyMcpName(name); + const definitionPath = path.join(availableDir, `${name}.json`); + + try { + const content = await fsPromises.readFile(definitionPath, 'utf8'); + const parsed = JSON.parse(content); + const validation = HeavyMcpDefinitionSchema.safeParse(parsed); + if (!validation.success) { + const firstIssue = validation.error.issues[0]; + const issuePath = firstIssue?.path.length ? ` at ${firstIssue.path.join('.')}` : ''; + const issueMessage = firstIssue?.message ?? 'expected an object with mcpServers'; + throw new Error(`Invalid heavy MCP definition '${name}' in ${definitionPath}${issuePath}: ${issueMessage}`); + } + return validation.data; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } + throw error; + } +} + +/** + * Write a heavy MCP definition file. + * + * @param availableDir - Directory containing available heavy MCPs + * @param name - Name of the heavy MCP (without .json extension) + * @param definition - The definition to write + */ +export async function writeHeavyMcpDefinition( + availableDir: string, + name: string, + definition: HeavyMcpDefinition +): Promise { + assertValidHeavyMcpName(name); + await fsPromises.mkdir(availableDir, { recursive: true }); + const definitionPath = path.join(availableDir, `${name}.json`); + const content = `${JSON.stringify(definition, null, 2)}\n`; + await fsPromises.writeFile(definitionPath, content, 'utf8'); +} + +/** + * List all available heavy MCP definitions. + * + * @param availableDir - Directory containing available heavy MCPs + * @returns Array of heavy MCP names (without .json extension) + */ +export async function listHeavyMcpDefinitions(availableDir: string): Promise { + try { + const files = await fsPromises.readdir(availableDir); + return files.filter((file) => file.endsWith('.json')).map((file) => file.replace(/\.json$/, '')); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return []; + } + throw error; + } +} + +/** + * Delete a heavy MCP definition file. + * + * @param availableDir - Directory containing available heavy MCPs + * @param name - Name of the heavy MCP (without .json extension) + */ +export async function deleteHeavyMcpDefinition(availableDir: string, name: string): Promise { + assertValidHeavyMcpName(name); + const definitionPath = path.join(availableDir, `${name}.json`); + await fsPromises.unlink(definitionPath).catch((error) => { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + }); +} diff --git a/src/heavy/index.ts b/src/heavy/index.ts new file mode 100644 index 0000000..0d260de --- /dev/null +++ b/src/heavy/index.ts @@ -0,0 +1,15 @@ +/** + * Heavy MCP management module. + * + * Provides on-demand loading for large MCP servers that consume significant + * context tokens when loaded. + */ + +export { + deleteHeavyMcpDefinition, + type HeavyMcpDefinition, + listHeavyMcpDefinitions, + readHeavyMcpDefinition, + writeHeavyMcpDefinition, +} from './definition.js'; +export { getDefaultHeavyPaths, type HeavyPaths, resolveHeavyPaths } from './paths.js'; diff --git a/src/heavy/paths.ts b/src/heavy/paths.ts new file mode 100644 index 0000000..1e57a12 --- /dev/null +++ b/src/heavy/paths.ts @@ -0,0 +1,49 @@ +/** + * Path resolution for heavy MCP management. + * + * Heavy MCPs are servers with large tool schemas that consume significant + * context tokens. This module provides path resolution for managing them. + */ + +import os from 'node:os'; +import path from 'node:path'; + +export interface HeavyPaths { + /** Base directory for heavy MCP management (~/.mcporter/heavy) */ + heavyDir: string; + /** Directory containing available heavy MCP definitions */ + availableDir: string; + /** Directory tracking active heavy MCPs */ + activeDir: string; +} + +/** + * Get the default heavy paths based on home directory. + */ +export function getDefaultHeavyPaths(): HeavyPaths { + const homeDir = os.homedir(); + const mcporterDir = path.join(homeDir, '.mcporter'); + + return { + heavyDir: path.join(mcporterDir, 'heavy'), + availableDir: path.join(mcporterDir, 'heavy', 'available'), + activeDir: path.join(mcporterDir, 'heavy', 'active'), + }; +} + +/** + * Resolve paths for heavy MCP management based on config path. + * + * @param configPath - Path to the main mcporter.json file + * @returns HeavyPaths object with resolved paths + */ +export function resolveHeavyPaths(configPath: string): HeavyPaths { + // Determine the mcporter directory from config path + const mcporterDir = path.dirname(configPath); + + return { + heavyDir: path.join(mcporterDir, 'heavy'), + availableDir: path.join(mcporterDir, 'heavy', 'available'), + activeDir: path.join(mcporterDir, 'heavy', 'active'), + }; +} diff --git a/tests/cli-help-shortcuts.test.ts b/tests/cli-help-shortcuts.test.ts index dc25767..172c44e 100644 --- a/tests/cli-help-shortcuts.test.ts +++ b/tests/cli-help-shortcuts.test.ts @@ -26,6 +26,8 @@ describe('mcporter help shortcuts (hidden)', () => { { args: ['call', 'help'], expectSnippet: 'Usage: mcporter call' }, { args: ['auth', '--help'], expectSnippet: 'Usage: mcporter auth' }, { args: ['auth', 'help'], expectSnippet: 'Usage: mcporter auth' }, + { args: ['heavy', '--help'], expectSnippet: 'Usage: mcporter heavy' }, + { args: ['heavy', 'help'], expectSnippet: 'Usage: mcporter heavy' }, { args: ['list', '--help'], expectSnippet: 'Usage: mcporter list' }, { args: ['list', 'help'], expectSnippet: 'Usage: mcporter list' }, ]; diff --git a/tests/heavy-command.test.ts b/tests/heavy-command.test.ts new file mode 100644 index 0000000..3909cfa --- /dev/null +++ b/tests/heavy-command.test.ts @@ -0,0 +1,714 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { handleHeavyCli } from '../src/cli/heavy-command.js'; + +describe('mcporter heavy CLI', () => { + let tempDir: string; + let configPath: string; + let availableDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-heavy-')); + configPath = path.join(tempDir, 'config', 'mcporter.json'); + availableDir = path.join(tempDir, 'config', 'heavy', 'available'); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile(configPath, JSON.stringify({ mcpServers: {} }, null, 2), 'utf8'); + await fs.mkdir(availableDir, { recursive: true }); + await writeHeavyDefinition('chrome-devtools', ['chrome-devtools']); + await writeHeavyDefinition('playwright', ['playwright']); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it('activates a heavy MCP and writes an active marker', async () => { + const logs: string[] = []; + const logSpy = vi.spyOn(console, 'log').mockImplementation((value?: unknown) => { + if (typeof value === 'string') { + logs.push(value); + } + }); + + await handleHeavyCli(['activate', 'chrome-devtools'], { configPath, rootDir: tempDir }); + + const config = JSON.parse(await fs.readFile(configPath, 'utf8')) as { + mcpServers: Record; + }; + expect(config.mcpServers['chrome-devtools']?.command).toBe('npx'); + const marker = JSON.parse( + await fs.readFile(path.join(tempDir, 'config', 'heavy', 'active', 'chrome-devtools.json'), 'utf8') + ) as { serverNames: string[] }; + expect(marker.serverNames).toEqual(['chrome-devtools']); + expect(logs).toContain('Activated: chrome-devtools'); + + logSpy.mockRestore(); + }); + + it('lists config-backed heavy MCPs as active even without marker files', async () => { + await fs.writeFile( + configPath, + JSON.stringify( + { + mcpServers: { + 'chrome-devtools': { + command: 'npx', + args: ['-y', 'chrome-devtools-mcp@latest'], + }, + }, + }, + null, + 2 + ), + 'utf8' + ); + + const logs: string[] = []; + const logSpy = vi.spyOn(console, 'log').mockImplementation((value?: unknown) => { + if (typeof value === 'string') { + logs.push(value); + } + }); + + await handleHeavyCli(['list'], { configPath, rootDir: tempDir }); + + expect(logs.join('\n')).toContain('chrome-devtools [active]'); + + logSpy.mockRestore(); + }); + + it('merges marker-based and config-backed heavy MCPs in mixed states', async () => { + await handleHeavyCli(['activate', 'chrome-devtools'], { configPath, rootDir: tempDir }); + await fs.writeFile( + configPath, + JSON.stringify( + { + mcpServers: { + 'chrome-devtools': { + command: 'npx', + args: ['-y', 'chrome-devtools-mcp@latest'], + }, + playwright: { + command: 'npx', + args: ['-y', 'playwright-mcp@latest'], + }, + }, + }, + null, + 2 + ), + 'utf8' + ); + + const logs: string[] = []; + const logSpy = vi.spyOn(console, 'log').mockImplementation((value?: unknown) => { + if (typeof value === 'string') { + logs.push(value); + } + }); + + await handleHeavyCli(['list'], { configPath, rootDir: tempDir }); + + const output = logs.join('\n'); + expect(output).toContain('chrome-devtools [active]'); + expect(output).toContain('playwright [active]'); + + logSpy.mockRestore(); + }); + + it('deactivates a heavy MCP and removes its config entry', async () => { + await handleHeavyCli(['activate', 'chrome-devtools'], { configPath, rootDir: tempDir }); + + const logs: string[] = []; + const logSpy = vi.spyOn(console, 'log').mockImplementation((value?: unknown) => { + if (typeof value === 'string') { + logs.push(value); + } + }); + + await handleHeavyCli(['deactivate', 'chrome-devtools'], { configPath, rootDir: tempDir }); + + const config = JSON.parse(await fs.readFile(configPath, 'utf8')) as { + mcpServers: Record; + }; + expect(config.mcpServers['chrome-devtools']).toBeUndefined(); + await expect(fs.access(path.join(tempDir, 'config', 'heavy', 'active', 'chrome-devtools.json'))).rejects.toThrow(); + expect(logs).toContain('Deactivated: chrome-devtools'); + + logSpy.mockRestore(); + }); + + it('deactivates config-backed heavy MCPs even when another heavy MCP has a marker', async () => { + await handleHeavyCli(['activate', 'chrome-devtools'], { configPath, rootDir: tempDir }); + await fs.writeFile( + configPath, + JSON.stringify( + { + mcpServers: { + 'chrome-devtools': { + command: 'npx', + args: ['-y', 'chrome-devtools-mcp@latest'], + }, + playwright: { + command: 'npx', + args: ['-y', 'playwright-mcp@latest'], + }, + }, + }, + null, + 2 + ), + 'utf8' + ); + + const logs: string[] = []; + const logSpy = vi.spyOn(console, 'log').mockImplementation((value?: unknown) => { + if (typeof value === 'string') { + logs.push(value); + } + }); + + await handleHeavyCli(['deactivate', 'playwright'], { configPath, rootDir: tempDir }); + + const config = JSON.parse(await fs.readFile(configPath, 'utf8')) as { + mcpServers: Record; + }; + expect(config.mcpServers.playwright).toBeUndefined(); + expect(config.mcpServers['chrome-devtools']).toBeDefined(); + expect(logs).toContain('Deactivated: playwright'); + + logSpy.mockRestore(); + }); + + it('does not treat same-name custom configs as active heavy MCPs', async () => { + await fs.writeFile( + configPath, + JSON.stringify( + { + mcpServers: { + playwright: { + command: 'node', + args: ['custom-playwright.js'], + }, + }, + }, + null, + 2 + ), + 'utf8' + ); + + const logs: string[] = []; + const logSpy = vi.spyOn(console, 'log').mockImplementation((value?: unknown) => { + if (typeof value === 'string') { + logs.push(value); + } + }); + + await handleHeavyCli(['list'], { configPath, rootDir: tempDir }); + + const output = logs.join('\n'); + expect(output).toContain(' playwright'); + expect(output).not.toContain('playwright [active]'); + + logSpy.mockRestore(); + }); + + it('does not list stale markers as active heavy MCPs', async () => { + const activePath = path.join(tempDir, 'config', 'heavy', 'active', 'chrome-devtools.json'); + await fs.mkdir(path.dirname(activePath), { recursive: true }); + await fs.writeFile( + activePath, + JSON.stringify({ activated: 'already', serverNames: ['chrome-devtools'] }, null, 2), + 'utf8' + ); + + const logs: string[] = []; + const logSpy = vi.spyOn(console, 'log').mockImplementation((value?: unknown) => { + if (typeof value === 'string') { + logs.push(value); + } + }); + + await handleHeavyCli(['list'], { configPath, rootDir: tempDir }); + + const output = logs.join('\n'); + expect(output).toContain(' chrome-devtools'); + expect(output).not.toContain('chrome-devtools [active]'); + + logSpy.mockRestore(); + }); + + it('lists marker-backed heavy MCPs as active when the available definition drifts', async () => { + await handleHeavyCli(['activate', 'playwright'], { configPath, rootDir: tempDir }); + await fs.writeFile( + path.join(availableDir, 'playwright.json'), + JSON.stringify( + { + mcpServers: { + playwright: { + command: 'npx', + args: ['-y', 'playwright-mcp@next'], + }, + }, + }, + null, + 2 + ), + 'utf8' + ); + + const logs: string[] = []; + const logSpy = vi.spyOn(console, 'log').mockImplementation((value?: unknown) => { + if (typeof value === 'string') { + logs.push(value); + } + }); + + await handleHeavyCli(['list'], { configPath, rootDir: tempDir }); + + const output = logs.join('\n'); + expect(output).toContain('playwright [active]'); + + logSpy.mockRestore(); + }); + + it('does not delete same-name custom configs during deactivate fallback', async () => { + await fs.writeFile( + configPath, + JSON.stringify( + { + mcpServers: { + playwright: { + command: 'node', + args: ['custom-playwright.js'], + }, + }, + }, + null, + 2 + ), + 'utf8' + ); + + const logs: string[] = []; + const logSpy = vi.spyOn(console, 'log').mockImplementation((value?: unknown) => { + if (typeof value === 'string') { + logs.push(value); + } + }); + + await handleHeavyCli(['deactivate', 'playwright'], { configPath, rootDir: tempDir }); + + const config = JSON.parse(await fs.readFile(configPath, 'utf8')) as { + mcpServers: Record; + }; + expect(config.mcpServers.playwright).toEqual({ + command: 'node', + args: ['custom-playwright.js'], + }); + expect(logs).toContain("Heavy MCP 'playwright' is not active."); + + logSpy.mockRestore(); + }); + + it('rejects malformed heavy definitions with a validation error', async () => { + await fs.writeFile(path.join(availableDir, 'broken.json'), JSON.stringify({ nope: true }, null, 2), 'utf8'); + + await expect(handleHeavyCli(['activate', 'broken'], { configPath, rootDir: tempDir })).rejects.toThrow( + /Invalid heavy MCP definition 'broken'/ + ); + }); + + it('rejects empty heavy definitions with a validation error', async () => { + await fs.writeFile(path.join(availableDir, 'empty.json'), JSON.stringify({ mcpServers: {} }, null, 2), 'utf8'); + + await expect(handleHeavyCli(['activate', 'empty'], { configPath, rootDir: tempDir })).rejects.toThrow( + /Invalid heavy MCP definition 'empty'.*must contain at least one server/ + ); + }); + + it('ignores unrelated invalid heavy definitions during activation and listing', async () => { + await fs.writeFile(path.join(availableDir, 'broken.json'), JSON.stringify({ nope: true }, null, 2), 'utf8'); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const logs: string[] = []; + const logSpy = vi.spyOn(console, 'log').mockImplementation((value?: unknown) => { + if (typeof value === 'string') { + logs.push(value); + } + }); + + await expect( + handleHeavyCli(['activate', 'chrome-devtools'], { configPath, rootDir: tempDir }) + ).resolves.toBeUndefined(); + await expect(handleHeavyCli(['list'], { configPath, rootDir: tempDir })).resolves.toBeUndefined(); + + expect(logs.join('\n')).toContain('chrome-devtools [active]'); + expect(warnSpy).toHaveBeenCalled(); + + logSpy.mockRestore(); + warnSpy.mockRestore(); + }); + + it('ignores unrelated invalid heavy definitions during deactivate', async () => { + await handleHeavyCli(['activate', 'chrome-devtools'], { configPath, rootDir: tempDir }); + await fs.writeFile(path.join(availableDir, 'broken.json'), JSON.stringify({ nope: true }, null, 2), 'utf8'); + const logs: string[] = []; + const logSpy = vi.spyOn(console, 'log').mockImplementation((value?: unknown) => { + if (typeof value === 'string') { + logs.push(value); + } + }); + + await expect( + handleHeavyCli(['deactivate', 'chrome-devtools'], { configPath, rootDir: tempDir }) + ).resolves.toBeUndefined(); + + expect(logs).toContain('Deactivated: chrome-devtools'); + + logSpy.mockRestore(); + }); + + it('rejects unsafe heavy MCP names before any filesystem writes', async () => { + const originalConfig = await fs.readFile(configPath, 'utf8'); + + await expect(handleHeavyCli(['activate', '../../mcporter'], { configPath, rootDir: tempDir })).rejects.toThrow( + /Invalid heavy MCP name/ + ); + + await expect(fs.readFile(configPath, 'utf8')).resolves.toBe(originalConfig); + }); + + it('reactivates when only a stale marker file remains', async () => { + const activePath = path.join(tempDir, 'config', 'heavy', 'active', 'chrome-devtools.json'); + await fs.mkdir(path.dirname(activePath), { recursive: true }); + await fs.writeFile( + activePath, + JSON.stringify({ activated: 'already', serverNames: ['stale-server'] }, null, 2), + 'utf8' + ); + + const logs: string[] = []; + const logSpy = vi.spyOn(console, 'log').mockImplementation((value?: unknown) => { + if (typeof value === 'string') { + logs.push(value); + } + }); + + await expect( + handleHeavyCli(['activate', 'chrome-devtools'], { configPath, rootDir: tempDir }) + ).resolves.toBeUndefined(); + + const config = JSON.parse(await fs.readFile(configPath, 'utf8')) as { + mcpServers: Record; + }; + expect(config.mcpServers['chrome-devtools']?.command).toBe('npx'); + const marker = JSON.parse(await fs.readFile(activePath, 'utf8')) as { serverNames: string[] }; + expect(marker.serverNames).toEqual(['chrome-devtools']); + expect(logs).toContain('Activated: chrome-devtools'); + + logSpy.mockRestore(); + }); + + it('treats a marker-backed heavy MCP as already active when the current definition shrinks', async () => { + await writeHeavyDefinition('browser-suite', ['playwright', 'chrome-devtools']); + await handleHeavyCli(['activate', 'browser-suite'], { configPath, rootDir: tempDir }); + await writeHeavyDefinition('browser-suite', ['playwright']); + + const logs: string[] = []; + const logSpy = vi.spyOn(console, 'log').mockImplementation((value?: unknown) => { + if (typeof value === 'string') { + logs.push(value); + } + }); + + await expect( + handleHeavyCli(['activate', 'browser-suite'], { configPath, rootDir: tempDir }) + ).resolves.toBeUndefined(); + + const config = JSON.parse(await fs.readFile(configPath, 'utf8')) as { + mcpServers: Record; + }; + expect(config.mcpServers.playwright?.command).toBe('npx'); + expect(config.mcpServers['chrome-devtools']?.command).toBe('npx'); + const marker = JSON.parse( + await fs.readFile(path.join(tempDir, 'config', 'heavy', 'active', 'browser-suite.json'), 'utf8') + ) as { serverNames: string[] }; + expect(marker.serverNames).toEqual(['playwright', 'chrome-devtools']); + expect(logs).toContain("Heavy MCP 'browser-suite' is already active."); + + logSpy.mockRestore(); + }); + + it('rejects activation when it would overwrite an existing server config', async () => { + await fs.writeFile( + configPath, + JSON.stringify( + { + mcpServers: { + 'chrome-devtools': { + command: 'node', + args: ['custom-devtools.js'], + }, + }, + }, + null, + 2 + ), + 'utf8' + ); + + const originalConfig = await fs.readFile(configPath, 'utf8'); + + await expect(handleHeavyCli(['activate', 'chrome-devtools'], { configPath, rootDir: tempDir })).rejects.toThrow( + /Cannot activate heavy MCP 'chrome-devtools' because these server entries already exist with different settings: 'chrome-devtools'/ + ); + + await expect(fs.readFile(configPath, 'utf8')).resolves.toBe(originalConfig); + await expect(fs.access(path.join(tempDir, 'config', 'heavy', 'active', 'chrome-devtools.json'))).rejects.toThrow(); + }); + + it('does not deactivate drifted marker-backed configs when the definition still exists', async () => { + await handleHeavyCli(['activate', 'playwright'], { configPath, rootDir: tempDir }); + await fs.writeFile( + configPath, + JSON.stringify( + { + mcpServers: { + playwright: { + command: 'node', + args: ['custom-playwright.js'], + }, + }, + }, + null, + 2 + ), + 'utf8' + ); + + const logs: string[] = []; + const logSpy = vi.spyOn(console, 'log').mockImplementation((value?: unknown) => { + if (typeof value === 'string') { + logs.push(value); + } + }); + + await expect( + handleHeavyCli(['deactivate', 'playwright'], { configPath, rootDir: tempDir }) + ).resolves.toBeUndefined(); + + const config = JSON.parse(await fs.readFile(configPath, 'utf8')) as { + mcpServers: Record; + }; + expect(config.mcpServers.playwright).toEqual({ + command: 'node', + args: ['custom-playwright.js'], + }); + await expect( + fs.readFile(path.join(tempDir, 'config', 'heavy', 'active', 'playwright.json'), 'utf8') + ).resolves.toContain('playwright'); + expect(logs).toContain("Heavy MCP 'playwright' is not active."); + + logSpy.mockRestore(); + }); + + it('deactivates an active heavy MCP even when its definition file becomes malformed', async () => { + await handleHeavyCli(['activate', 'chrome-devtools'], { configPath, rootDir: tempDir }); + await fs.writeFile( + path.join(availableDir, 'chrome-devtools.json'), + JSON.stringify({ nope: true }, null, 2), + 'utf8' + ); + + const logs: string[] = []; + const logSpy = vi.spyOn(console, 'log').mockImplementation((value?: unknown) => { + if (typeof value === 'string') { + logs.push(value); + } + }); + + await expect( + handleHeavyCli(['deactivate', 'chrome-devtools'], { configPath, rootDir: tempDir }) + ).resolves.toBeUndefined(); + + const config = JSON.parse(await fs.readFile(configPath, 'utf8')) as { + mcpServers: Record; + }; + expect(config.mcpServers['chrome-devtools']).toBeUndefined(); + await expect(fs.access(path.join(tempDir, 'config', 'heavy', 'active', 'chrome-devtools.json'))).rejects.toThrow(); + expect(logs).toContain('Deactivated: chrome-devtools'); + + logSpy.mockRestore(); + }); + + it('does not deactivate drifted marker-backed configs when the definition becomes malformed', async () => { + await handleHeavyCli(['activate', 'chrome-devtools'], { configPath, rootDir: tempDir }); + await fs.writeFile( + configPath, + JSON.stringify( + { + mcpServers: { + 'chrome-devtools': { + command: 'node', + args: ['custom-devtools.js'], + }, + }, + }, + null, + 2 + ), + 'utf8' + ); + await fs.writeFile( + path.join(availableDir, 'chrome-devtools.json'), + JSON.stringify({ nope: true }, null, 2), + 'utf8' + ); + + const logs: string[] = []; + const logSpy = vi.spyOn(console, 'log').mockImplementation((value?: unknown) => { + if (typeof value === 'string') { + logs.push(value); + } + }); + + await expect( + handleHeavyCli(['deactivate', 'chrome-devtools'], { configPath, rootDir: tempDir }) + ).resolves.toBeUndefined(); + + const config = JSON.parse(await fs.readFile(configPath, 'utf8')) as { + mcpServers: Record; + }; + expect(config.mcpServers['chrome-devtools']).toEqual({ + command: 'node', + args: ['custom-devtools.js'], + }); + await expect( + fs.readFile(path.join(tempDir, 'config', 'heavy', 'active', 'chrome-devtools.json'), 'utf8') + ).resolves.toContain('chrome-devtools'); + expect(logs).toContain("Heavy MCP 'chrome-devtools' is not active."); + + logSpy.mockRestore(); + }); + + it('deactivates all originally activated servers when the current definition shrinks', async () => { + await writeHeavyDefinition('browser-suite', ['playwright', 'chrome-devtools']); + await handleHeavyCli(['activate', 'browser-suite'], { configPath, rootDir: tempDir }); + await writeHeavyDefinition('browser-suite', ['playwright']); + + const logs: string[] = []; + const logSpy = vi.spyOn(console, 'log').mockImplementation((value?: unknown) => { + if (typeof value === 'string') { + logs.push(value); + } + }); + + await expect( + handleHeavyCli(['deactivate', 'browser-suite'], { configPath, rootDir: tempDir }) + ).resolves.toBeUndefined(); + + const config = JSON.parse(await fs.readFile(configPath, 'utf8')) as { + mcpServers: Record; + }; + expect(config.mcpServers.playwright).toBeUndefined(); + expect(config.mcpServers['chrome-devtools']).toBeUndefined(); + await expect(fs.access(path.join(tempDir, 'config', 'heavy', 'active', 'browser-suite.json'))).rejects.toThrow(); + expect(logs).toContain('Deactivated: browser-suite'); + + logSpy.mockRestore(); + }); + + it('deactivates the current definition when marker metadata still lists removed servers', async () => { + await writeHeavyDefinition('browser-suite', ['playwright', 'chrome-devtools']); + await handleHeavyCli(['activate', 'browser-suite'], { configPath, rootDir: tempDir }); + await writeHeavyDefinition('browser-suite', ['playwright']); + await fs.writeFile( + configPath, + JSON.stringify( + { + mcpServers: { + playwright: { + command: 'npx', + args: ['-y', 'playwright-mcp@latest'], + }, + }, + }, + null, + 2 + ), + 'utf8' + ); + + const logs: string[] = []; + const logSpy = vi.spyOn(console, 'log').mockImplementation((value?: unknown) => { + if (typeof value === 'string') { + logs.push(value); + } + }); + + await expect( + handleHeavyCli(['deactivate', 'browser-suite'], { configPath, rootDir: tempDir }) + ).resolves.toBeUndefined(); + + const config = JSON.parse(await fs.readFile(configPath, 'utf8')) as { + mcpServers: Record; + }; + expect(config.mcpServers.playwright).toBeUndefined(); + await expect(fs.access(path.join(tempDir, 'config', 'heavy', 'active', 'browser-suite.json'))).rejects.toThrow(); + expect(logs).toContain('Deactivated: browser-suite'); + + logSpy.mockRestore(); + }); + + it('deactivates using marker metadata when the definition file is missing and names differ from the basename', async () => { + await writeHeavyDefinition('browser-suite', ['playwright', 'chrome-devtools']); + await handleHeavyCli(['activate', 'browser-suite'], { configPath, rootDir: tempDir }); + await fs.unlink(path.join(availableDir, 'browser-suite.json')); + + const logs: string[] = []; + const logSpy = vi.spyOn(console, 'log').mockImplementation((value?: unknown) => { + if (typeof value === 'string') { + logs.push(value); + } + }); + + await expect( + handleHeavyCli(['deactivate', 'browser-suite'], { configPath, rootDir: tempDir }) + ).resolves.toBeUndefined(); + + const config = JSON.parse(await fs.readFile(configPath, 'utf8')) as { + mcpServers: Record; + }; + expect(config.mcpServers['chrome-devtools']).toBeUndefined(); + expect(config.mcpServers.playwright).toBeUndefined(); + expect(logs).toContain('Deactivated: browser-suite'); + + logSpy.mockRestore(); + }); + + async function writeHeavyDefinition(name: string, serverNames: string[]): Promise { + await fs.writeFile( + path.join(availableDir, `${name}.json`), + JSON.stringify( + { + mcpServers: Object.fromEntries( + serverNames.map((serverName) => [ + serverName, + { + command: 'npx', + args: [`-y`, `${serverName}-mcp@latest`], + }, + ]) + ), + }, + null, + 2 + ), + 'utf8' + ); + } +});