From 25f6250499b51b16f19fdde6a4a99542bcd0e10a Mon Sep 17 00:00:00 2001 From: MQ37 Date: Mon, 18 May 2026 09:16:56 +0200 Subject: [PATCH 1/8] feat(mcp): add 'apify mcp install ' command Configures the Apify MCP server in six AI clients (claude-code, cursor, vscode, codex, kiro, antigravity) by writing the client's canonical config or shelling out to its own 'mcp add' CLI. --- docs/reference.md | 48 ++++ scripts/generate-cli-docs.ts | 5 + scripts/reference-template.md | 9 + src/commands/_register.ts | 2 + src/commands/cli-management/install.ts | 8 +- src/commands/mcp/_index.ts | 18 ++ src/commands/mcp/install.ts | 84 +++++++ src/lib/mcp/auth.ts | 23 ++ src/lib/mcp/clients.ts | 313 ++++++++++++++++++++++++ src/lib/mcp/exec-helpers.ts | 13 + src/lib/mcp/file-config.ts | 55 +++++ src/lib/mcp/url.ts | 25 ++ src/lib/utils.ts | 3 + test/local/commands/mcp/install.test.ts | 192 +++++++++++++++ 14 files changed, 793 insertions(+), 5 deletions(-) create mode 100644 src/commands/mcp/_index.ts create mode 100644 src/commands/mcp/install.ts create mode 100644 src/lib/mcp/auth.ts create mode 100644 src/lib/mcp/clients.ts create mode 100644 src/lib/mcp/exec-helpers.ts create mode 100644 src/lib/mcp/file-config.ts create mode 100644 src/lib/mcp/url.ts create mode 100644 test/local/commands/mcp/install.test.ts diff --git a/docs/reference.md b/docs/reference.md index 80d1da98f..cdd6715ad 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -1573,3 +1573,51 @@ FLAGS ``` + +### MCP + +Use these commands to configure the Apify MCP server in your favorite AI client (Claude Code, Cursor, VS Code, Codex CLI, Kiro, Antigravity). + + + +##### `apify mcp` + +```sh +DESCRIPTION + Configure the Apify MCP server in your favorite AI client (Claude Code, + Cursor, VS Code, ...). + +SUBCOMMANDS + mcp install Configure a local MCP client to use the Apify MCP + server. Writes (or merges) a server entry named 'apify' into the + client's config file, or runs the client's own 'mcp add' command + when available. +``` + +##### `apify mcp install` + +```sh +DESCRIPTION + Configure a local MCP client to use the Apify MCP server. Writes (or merges) a + server entry named 'apify' into the client's config file, or runs the + client's own 'mcp add' command when available. + +USAGE + $ apify mcp install [-t ] [--tools ] + [--url ] [-y] + +ARGUMENTS + client Target MCP client. One of: claude-code, cursor, vscode, codex, + kiro, antigravity. + +FLAGS + -t, --token= Apify API token to embed in the config. + Defaults to the token from 'apify login'. + --tools= Comma-separated tool IDs or Actor full names + to expose (forwarded as a '?tools=' query parameter). + --url= Apify MCP server URL. + -y, --yes Overwrite an existing 'apify' entry + without prompting. +``` + + diff --git a/scripts/generate-cli-docs.ts b/scripts/generate-cli-docs.ts index a2fe14b89..62cb9984f 100644 --- a/scripts/generate-cli-docs.ts +++ b/scripts/generate-cli-docs.ts @@ -113,6 +113,11 @@ const categories: Record = { { command: Commands.task }, { command: Commands.taskRun }, ], + 'mcp': [ + // + { command: Commands.mcp }, + { command: Commands.mcpInstall }, + ], }; await renderDocs(categories); diff --git a/scripts/reference-template.md b/scripts/reference-template.md index b8adb540e..fe3ca5dfe 100644 --- a/scripts/reference-template.md +++ b/scripts/reference-template.md @@ -110,3 +110,12 @@ These commands help you manage scheduled and configured Actor runs. Use them to + +### MCP + +Use these commands to configure the Apify MCP server in your favorite AI client (Claude Code, Cursor, VS Code, Codex CLI, Kiro, Antigravity). + + + + + diff --git a/src/commands/_register.ts b/src/commands/_register.ts index 9d07007b1..7f8901543 100644 --- a/src/commands/_register.ts +++ b/src/commands/_register.ts @@ -25,6 +25,7 @@ import { InitCommand } from './init.js'; import { KeyValueStoresIndexCommand } from './key-value-stores/_index.js'; import { LoginCommand } from './login.js'; import { LogoutCommand } from './logout.js'; +import { MCPIndexCommand } from './mcp/_index.js'; import { TopLevelPullCommand } from './pull.js'; import { ToplevelPushCommand } from './push.js'; import { RequestQueuesIndexCommand } from './request-queues/_index.js'; @@ -43,6 +44,7 @@ export const apifyCommands = [ BuildsIndexCommand, DatasetsIndexCommand, KeyValueStoresIndexCommand, + MCPIndexCommand, RequestQueuesIndexCommand, RunsIndexCommand, SecretsIndexCommand, diff --git a/src/commands/cli-management/install.ts b/src/commands/cli-management/install.ts index 7069b967c..afc15e54b 100644 --- a/src/commands/cli-management/install.ts +++ b/src/commands/cli-management/install.ts @@ -1,7 +1,6 @@ import assert from 'node:assert'; import { existsSync, openSync } from 'node:fs'; import { mkdir, readFile, symlink, unlink, writeFile } from 'node:fs/promises'; -import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; import { ReadStream } from 'node:tty'; @@ -12,11 +11,10 @@ import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { useCLIMetadata } from '../../lib/hooks/useCLIMetadata.js'; import { useYesNoConfirm } from '../../lib/hooks/user-confirmations/useYesNoConfirm.js'; import { error, info, simpleLog, success, warning } from '../../lib/outputs.js'; -import { detectShell, shellConfigFile, tildify } from '../../lib/utils.js'; +import { detectShell, shellConfigFile, tildify, userHomeDir } from '../../lib/utils.js'; import { cliDebugPrint } from '../../lib/utils/cliDebugPrint.js'; const pathToInstallMarker = (installPath: string) => join(installPath, '.install-marker'); -const HOMEDIR = () => process.env.HOME ?? homedir(); export class InstallCommand extends ApifyCommand { static override name = 'install' as const; @@ -77,7 +75,7 @@ export class InstallCommand extends ApifyCommand { } private async symlinkToLocalBin(installPath: string) { - const userHomeDirectory = HOMEDIR(); + const userHomeDirectory = userHomeDir(); cliDebugPrint('[install -> symlinkToLocalBin] user home directory', userHomeDirectory); @@ -211,7 +209,7 @@ export class InstallCommand extends ApifyCommand { return; } - const userHomeDirectory = HOMEDIR(); + const userHomeDirectory = userHomeDir(); cliDebugPrint('[install -> promptAddToShell] user home directory', userHomeDirectory); diff --git a/src/commands/mcp/_index.ts b/src/commands/mcp/_index.ts new file mode 100644 index 000000000..900bf35fc --- /dev/null +++ b/src/commands/mcp/_index.ts @@ -0,0 +1,18 @@ +import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; +import { MCPInstallCommand } from './install.js'; + +export class MCPIndexCommand extends ApifyCommand { + static override name = 'mcp' as const; + + static override description = `Configure the Apify MCP server in your favorite AI client (Claude Code, Cursor, VS Code, ...).`; + + static override group = 'MCP'; + + static override docsUrl = 'https://docs.apify.com/cli/docs/reference#apify-mcp'; + + static override subcommands = [MCPInstallCommand]; + + async run() { + this.printHelp(); + } +} diff --git a/src/commands/mcp/install.ts b/src/commands/mcp/install.ts new file mode 100644 index 000000000..81bcfe1da --- /dev/null +++ b/src/commands/mcp/install.ts @@ -0,0 +1,84 @@ +import process from 'node:process'; + +import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; +import { Args } from '../../lib/command-framework/args.js'; +import { Flags, YesFlag } from '../../lib/command-framework/flags.js'; +import { CommandExitCodes } from '../../lib/consts.js'; +import { resolveApifyToken } from '../../lib/mcp/auth.js'; +import { getClientHandler, isSupportedClient, SUPPORTED_CLIENTS } from '../../lib/mcp/clients.js'; +import { buildMcpUrl, DEFAULT_MCP_URL } from '../../lib/mcp/url.js'; +import { error } from '../../lib/outputs.js'; + +export class MCPInstallCommand extends ApifyCommand { + static override name = 'install' as const; + + static override description = `Configure a local MCP client to use the Apify MCP server. Writes (or merges) a server entry named 'apify' into the client's config file, or runs the client's own 'mcp add' command when available.`; + + static override group = 'MCP'; + + static override interactive = true; + + static override interactiveNote = + 'Prompts before overwriting an existing config entry. Pass --yes to overwrite without prompting.'; + + static override examples = [ + { + description: 'Add Apify to Claude Code using the stored API token.', + command: 'apify mcp install claude-code', + }, + { + description: 'Add Apify to Cursor.', + command: 'apify mcp install cursor', + }, + { + description: `Add only the 'search-actors' tool and the 'apify/rag-web-browser' Actor to VS Code.`, + command: 'apify mcp install vscode --tools search-actors,apify/rag-web-browser', + }, + { + description: 'Add Apify to Codex CLI with an explicit token (non-interactive).', + command: 'apify mcp install codex --token apify_api_xxxxx --yes', + }, + ]; + + static override docsUrl = 'https://docs.apify.com/cli/docs/reference#apify-mcp-install'; + + static override args = { + client: Args.string({ + required: true, + description: `Target MCP client. One of: ${SUPPORTED_CLIENTS.join(', ')}.`, + }), + }; + + static override flags = { + ...YesFlag(`Overwrite an existing 'apify' entry without prompting.`), + token: Flags.string({ + char: 't', + description: `Apify API token to embed in the config. Defaults to the token from 'apify login'.`, + }), + url: Flags.string({ + description: 'Apify MCP server URL.', + default: DEFAULT_MCP_URL, + }), + tools: Flags.string({ + description: `Comma-separated tool IDs or Actor full names to expose (forwarded as a '?tools=' query parameter).`, + }), + }; + + async run() { + const { client } = this.args; + const { token: tokenFlag, url: baseUrl, tools, yes } = this.flags; + + if (!isSupportedClient(client)) { + error({ + message: `Unknown MCP client '${client}'. Supported clients: ${SUPPORTED_CLIENTS.join(', ')}.`, + }); + process.exitCode = CommandExitCodes.InvalidInput; + return; + } + + const token = await resolveApifyToken(tokenFlag); + if (!token) return; + + await getClientHandler(client)({ url: buildMcpUrl(baseUrl, tools), token, yes }); + } +} diff --git a/src/lib/mcp/auth.ts b/src/lib/mcp/auth.ts new file mode 100644 index 000000000..f3e0c95f1 --- /dev/null +++ b/src/lib/mcp/auth.ts @@ -0,0 +1,23 @@ +import process from 'node:process'; + +import { CommandExitCodes } from '../consts.js'; +import { error } from '../outputs.js'; +import { getLocalUserInfo } from '../utils.js'; + +/** + * Resolution order: --token flag → APIFY_TOKEN env → stored login. + * Prints a user-facing error and sets process.exitCode when no token is available. + */ +export async function resolveApifyToken(tokenFlag: string | undefined): Promise { + if (tokenFlag) return tokenFlag; + if (process.env.APIFY_TOKEN) return process.env.APIFY_TOKEN; + + const userInfo = await getLocalUserInfo(); + if (userInfo.token) return userInfo.token; + + error({ + message: `You are not logged in to Apify. Run 'apify login' first, or pass --token .`, + }); + process.exitCode = CommandExitCodes.MissingAuth; + return null; +} diff --git a/src/lib/mcp/clients.ts b/src/lib/mcp/clients.ts new file mode 100644 index 000000000..18e7e94ca --- /dev/null +++ b/src/lib/mcp/clients.ts @@ -0,0 +1,313 @@ +import { join } from 'node:path'; +import process from 'node:process'; + +import chalk from 'chalk'; +import { execa } from 'execa'; +import which from 'which'; + +import { CommandExitCodes } from '../consts.js'; +import { error, run, simpleLog, success } from '../outputs.js'; +import { tildify, userHomeDir } from '../utils.js'; +import { describeExecaError } from './exec-helpers.js'; +import { confirmOverwrite, readJsonConfig, writeJsonConfig } from './file-config.js'; +import { maskToken } from './url.js'; + +export const SUPPORTED_CLIENTS = ['claude-code', 'cursor', 'vscode', 'codex', 'kiro', 'antigravity'] as const; + +export type ClientName = (typeof SUPPORTED_CLIENTS)[number]; + +export function isSupportedClient(value: string): value is ClientName { + return (SUPPORTED_CLIENTS as readonly string[]).includes(value); +} + +export interface InstallContext { + url: string; + token: string; + yes: boolean; +} + +type ClientHandler = (ctx: InstallContext) => Promise; + +const SERVER_KEY = 'apify'; + +interface InstallResult { + clientLabel: string; + serverUrl: string; + authDescription: string; + configPath?: string; + nextSteps: string[]; + docsUrl: string; +} + +function printResult(result: InstallResult): void { + success({ message: `Apify MCP server configured for ${result.clientLabel}.` }); + + const lines = [ + '', + ` ${chalk.yellow('Server URL:')} ${result.serverUrl}`, + ` ${chalk.yellow('Auth:')} ${result.authDescription}`, + ]; + if (result.configPath) { + lines.push(` ${chalk.yellow('Config:')} ${tildify(result.configPath)}`); + } + lines.push('', ` ${chalk.yellow('Next steps:')}`); + for (const [index, step] of result.nextSteps.entries()) { + lines.push(` ${index + 1}. ${step}`); + } + lines.push('', ` ${chalk.yellow('Docs:')} ${result.docsUrl}`); + + simpleLog({ message: lines.join('\n') }); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Load the JSON config and confirm an overwrite if an entry under SERVER_KEY already exists. + * Returns null if the user declined; otherwise returns the parsed config and the (possibly empty) servers map at topLevelKey. + */ +async function readConfigForOverwrite({ + filePath, + topLevelKey, + yes, +}: { + filePath: string; + topLevelKey: string; + yes: boolean; +}): Promise<{ config: Record; servers: Record } | null> { + const config = await readJsonConfig(filePath); + const servers = isRecord(config[topLevelKey]) ? (config[topLevelKey] as Record) : {}; + + if (SERVER_KEY in servers) { + const ok = await confirmOverwrite({ filePath, entryKey: SERVER_KEY, yes }); + if (!ok) { + simpleLog({ message: 'No changes written.' }); + return null; + } + } + + return { config, servers }; +} + +/** Convenience for the common shape: read+confirm, write a single server entry, return whether the write happened. */ +async function mergeServerEntry({ + filePath, + topLevelKey, + serverEntry, + yes, +}: { + filePath: string; + topLevelKey: string; + serverEntry: Record; + yes: boolean; +}): Promise { + const result = await readConfigForOverwrite({ filePath, topLevelKey, yes }); + if (!result) return false; + + result.servers[SERVER_KEY] = serverEntry; + result.config[topLevelKey] = result.servers; + await writeJsonConfig(filePath, result.config); + return true; +} + +const claudeCodeHandler: ClientHandler = async ({ url, token }) => { + const claudeBin = await which('claude', { nothrow: true }); + + if (!claudeBin) { + // --scope user matches the other clients' user-wide config locations; placeholder avoids printing the real token. + const manualCommand = `claude mcp add --transport http --scope user ${SERVER_KEY} "${url}" --header "Authorization: Bearer "`; + error({ + message: `The 'claude' CLI was not found on PATH. Install Claude Code (https://docs.anthropic.com/en/docs/claude-code) and re-run, or add the server manually:\n\n ${manualCommand}`, + }); + process.exitCode = CommandExitCodes.NotFound; + return; + } + + // Raw execa (no shell:true) — execWithLog joins args into a shell string, which breaks values with spaces (e.g. 'Authorization: Bearer …'). + // Per Anthropic docs, all options must come before the server name. + const args = [ + 'mcp', + 'add', + '--transport', + 'http', + '--scope', + 'user', + SERVER_KEY, + url, + '--header', + `Authorization: Bearer ${token}`, + ]; + run({ + message: `claude mcp add --transport http --scope user ${SERVER_KEY} "${url}" --header "Authorization: Bearer ${maskToken(token)}"`, + }); + + try { + // Discard child stdout — 'claude mcp add' echoes the Authorization header on success. stderr stays inherited for live error output. + await execa('claude', args, { stdio: ['ignore', 'ignore', 'inherit'] }); + } catch (err) { + error({ message: `Failed to add the MCP server via Claude Code: ${describeExecaError(err, 'claude')}` }); + process.exitCode = CommandExitCodes.RunFailed; + return; + } + + printResult({ + clientLabel: 'Claude Code', + serverUrl: url, + authDescription: `Bearer ${maskToken(token)} (stored by Claude Code)`, + nextSteps: [`Run 'claude mcp list' to confirm the apify server is registered.`], + docsUrl: 'https://docs.anthropic.com/en/docs/claude-code/mcp', + }); +}; + +const cursorHandler: ClientHandler = async ({ url, token, yes }) => { + const filePath = join(userHomeDir(), '.cursor', 'mcp.json'); + const serverEntry = { url, headers: { Authorization: `Bearer ${token}` } }; + + const wrote = await mergeServerEntry({ filePath, topLevelKey: 'mcpServers', serverEntry, yes }); + if (!wrote) return; + + printResult({ + clientLabel: 'Cursor', + serverUrl: url, + authDescription: `Bearer ${maskToken(token)}`, + configPath: filePath, + nextSteps: [ + 'Restart Cursor.', + 'Open Cursor Settings → Features → Model Context Protocol.', + `Verify '${SERVER_KEY}' is listed and enabled.`, + ], + docsUrl: 'https://cursor.com/docs/mcp', + }); +}; + +function vscodeConfigPath(): string { + switch (process.platform) { + case 'darwin': + return join(userHomeDir(), 'Library', 'Application Support', 'Code', 'User', 'mcp.json'); + case 'win32': { + const appData = process.env.APPDATA ?? join(userHomeDir(), 'AppData', 'Roaming'); + return join(appData, 'Code', 'User', 'mcp.json'); + } + default: + return join(userHomeDir(), '.config', 'Code', 'User', 'mcp.json'); + } +} + +const vscodeHandler: ClientHandler = async ({ url, token, yes }) => { + const filePath = vscodeConfigPath(); + const serverEntry = { + type: 'http', + url, + headers: { Authorization: `Bearer ${token}` }, + }; + + const wrote = await mergeServerEntry({ filePath, topLevelKey: 'servers', serverEntry, yes }); + if (!wrote) return; + + printResult({ + clientLabel: 'VS Code', + serverUrl: url, + authDescription: `Bearer ${maskToken(token)}`, + configPath: filePath, + nextSteps: ['Restart VS Code.', 'Open the Chat view and confirm Apify tools appear.'], + docsUrl: 'https://code.visualstudio.com/docs/copilot/customization/mcp-servers', + }); +}; + +const codexHandler: ClientHandler = async ({ url }) => { + const codexBin = await which('codex', { nothrow: true }); + const tomlPath = join(userHomeDir(), '.codex', 'config.toml'); + + if (!codexBin) { + const tomlSnippet = [`[mcp_servers.${SERVER_KEY}]`, `url = "${url}"`, `bearer_token_env_var = "APIFY_TOKEN"`].join( + '\n', + ); + error({ + message: `The 'codex' CLI was not found on PATH. Install Codex (https://developers.openai.com/codex) and re-run, or add this entry manually to ${tildify(tomlPath)}:\n\n${tomlSnippet}\n\nThen export your token in your shell rc:\n\n export APIFY_TOKEN=`, + }); + process.exitCode = CommandExitCodes.NotFound; + return; + } + + const args = ['mcp', 'add', SERVER_KEY, '--url', url, '--bearer-token-env-var', 'APIFY_TOKEN']; + run({ message: `codex ${args.join(' ')}` }); + + try { + // Discard child stdout — codex echoes the configured entry on success. stderr stays inherited for live error output. + await execa('codex', args, { stdio: ['ignore', 'ignore', 'inherit'] }); + } catch (err) { + error({ message: `Failed to add the MCP server via Codex: ${describeExecaError(err, 'codex')}` }); + process.exitCode = CommandExitCodes.RunFailed; + return; + } + + printResult({ + clientLabel: 'Codex CLI', + serverUrl: url, + authDescription: `Bearer token from APIFY_TOKEN environment variable`, + configPath: tomlPath, + nextSteps: [ + `Export your Apify token in your shell rc: export APIFY_TOKEN=`, + `Run 'codex mcp' to list configured servers.`, + `Start Codex and run /mcp to confirm Apify is connected.`, + ], + docsUrl: 'https://developers.openai.com/codex/mcp', + }); +}; + +const kiroHandler: ClientHandler = async ({ url, token, yes }) => { + const filePath = join(userHomeDir(), '.kiro', 'settings', 'mcp.json'); + const serverEntry = { + url, + headers: { Authorization: `Bearer ${token}` }, + disabled: false, + autoApprove: [] as string[], + }; + + const wrote = await mergeServerEntry({ filePath, topLevelKey: 'mcpServers', serverEntry, yes }); + if (!wrote) return; + + printResult({ + clientLabel: 'Kiro', + serverUrl: url, + authDescription: `Bearer ${maskToken(token)}`, + configPath: filePath, + nextSteps: ['Restart Kiro.', 'Open Kiro → MCP Servers and verify the apify entry is enabled.'], + docsUrl: 'https://kiro.dev/docs/mcp/configuration/', + }); +}; + +const antigravityHandler: ClientHandler = async ({ url, token, yes }) => { + const filePath = join(userHomeDir(), '.gemini', 'antigravity', 'mcp_config.json'); + // Antigravity uses 'serverUrl' (not 'url'); see https://antigravity.google/docs/mcp. + const serverEntry = { serverUrl: url, headers: { Authorization: `Bearer ${token}` } }; + + const wrote = await mergeServerEntry({ filePath, topLevelKey: 'mcpServers', serverEntry, yes }); + if (!wrote) return; + + printResult({ + clientLabel: 'Antigravity', + serverUrl: url, + authDescription: `Bearer ${maskToken(token)}`, + configPath: filePath, + nextSteps: [ + 'Restart Antigravity or reload the agent panel.', + 'Open the MCP Store to confirm the apify entry is loaded.', + ], + docsUrl: 'https://antigravity.google/docs/mcp', + }); +}; + +const HANDLERS: Record = { + 'claude-code': claudeCodeHandler, + cursor: cursorHandler, + vscode: vscodeHandler, + codex: codexHandler, + kiro: kiroHandler, + antigravity: antigravityHandler, +}; + +export function getClientHandler(name: ClientName): ClientHandler { + return HANDLERS[name]; +} diff --git a/src/lib/mcp/exec-helpers.ts b/src/lib/mcp/exec-helpers.ts new file mode 100644 index 000000000..91ad0921a --- /dev/null +++ b/src/lib/mcp/exec-helpers.ts @@ -0,0 +1,13 @@ +import type { ExecaError } from 'execa'; + +function isExecaError(err: unknown): err is ExecaError { + return typeof err === 'object' && err !== null && 'shortMessage' in err && 'command' in err; +} + +/** Build a user-facing description of an execa failure, falling back to signal / shortMessage when exitCode is null. */ +export function describeExecaError(err: unknown, cmd: string): string { + if (!isExecaError(err)) return err instanceof Error ? err.message : String(err); + if (err.exitCode != null) return `${cmd} exited with code ${err.exitCode}`; + if (err.signal) return `${cmd} exited due to signal ${err.signal}`; + return err.shortMessage ?? err.message; +} diff --git a/src/lib/mcp/file-config.ts b/src/lib/mcp/file-config.ts new file mode 100644 index 000000000..73164debd --- /dev/null +++ b/src/lib/mcp/file-config.ts @@ -0,0 +1,55 @@ +import { existsSync } from 'node:fs'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +import { useYesNoConfirm } from '../hooks/user-confirmations/useYesNoConfirm.js'; +import { tildify } from '../utils.js'; + +// Client config files are user-editable, so we cannot rely on schema invariants. Surface a readable error instead of letting SyntaxError bubble up. +export async function readJsonConfig(filePath: string): Promise> { + if (!existsSync(filePath)) return {}; + + const raw = await readFile(filePath, 'utf-8'); + const trimmed = raw.trim(); + if (!trimmed) return {}; + + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch (err) { + if (err instanceof SyntaxError) { + // VS Code's mcp.json supports JSONC (comments, trailing commas); plain JSON.parse cannot read those. + const hint = /\/\/|\/\*/.test(trimmed) + ? ' The file appears to contain comments (JSONC); add the apify entry manually.' + : ''; + throw new Error(`Cannot parse ${tildify(filePath)}: ${err.message}.${hint}`); + } + throw err; + } + + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`${tildify(filePath)} is not a JSON object. Fix or remove the file and try again.`); + } + return parsed as Record; +} + +export async function writeJsonConfig(filePath: string, contents: Record): Promise { + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(filePath, `${JSON.stringify(contents, null, 2)}\n`, 'utf-8'); +} + +interface ConfirmOverwriteOptions { + filePath: string; + entryKey: string; + yes: boolean; +} + +export async function confirmOverwrite({ filePath, entryKey, yes }: ConfirmOverwriteOptions): Promise { + // Skip the existing-entry preview — it contains a bearer token we should not reprint. + return useYesNoConfirm({ + message: `A server entry named '${entryKey}' already exists in ${tildify(filePath)}. Overwrite it?`, + default: false, + providedConfirmFromStdin: yes || undefined, + errorMessageForStdin: `An '${entryKey}' entry already exists in ${tildify(filePath)}. Re-run with --yes to overwrite.`, + }); +} diff --git a/src/lib/mcp/url.ts b/src/lib/mcp/url.ts new file mode 100644 index 000000000..70dfcf8bb --- /dev/null +++ b/src/lib/mcp/url.ts @@ -0,0 +1,25 @@ +export const DEFAULT_MCP_URL = 'https://mcp.apify.com'; + +const MASK_VISIBLE_PREFIX_CHARS = 10; +const MASK_VISIBLE_SUFFIX_CHARS = 4; + +export function buildMcpUrl(baseUrl: string, tools?: string): string { + if (!tools) return baseUrl; + + const normalized = tools + .split(',') + .map((part) => part.trim()) + .filter(Boolean) + .join(','); + + if (!normalized) return baseUrl; + + const separator = baseUrl.includes('?') ? '&' : '?'; + return `${baseUrl}${separator}tools=${normalized}`; +} + +/** Token shorter than visible prefix + suffix is masked entirely to avoid leaking it whole. */ +export function maskToken(token: string): string { + if (token.length <= MASK_VISIBLE_PREFIX_CHARS + MASK_VISIBLE_SUFFIX_CHARS) return '***'; + return `${token.slice(0, MASK_VISIBLE_PREFIX_CHARS)}...${token.slice(-MASK_VISIBLE_SUFFIX_CHARS)}`; +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 852d1b242..405df1a36 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -779,6 +779,9 @@ export function printJsonToStdout(object: unknown) { console.log(JSON.stringify(object, null, 2)); } +/** Like `os.homedir()` but honors an explicit `$HOME` override on Windows (where `homedir()` reads `USERPROFILE` instead). */ +export const userHomeDir = () => process.env.HOME ?? homedir(); + export const tildify = (path: string) => { if (path.startsWith(homedir())) { return path.replace(homedir(), '~'); diff --git a/test/local/commands/mcp/install.test.ts b/test/local/commands/mcp/install.test.ts new file mode 100644 index 000000000..241854299 --- /dev/null +++ b/test/local/commands/mcp/install.test.ts @@ -0,0 +1,192 @@ +import { existsSync } from 'node:fs'; +import { mkdir, readFile, rm } from 'node:fs/promises'; +import process from 'node:process'; + +// Force `useYesNoConfirm` down its non-interactive path so the overwrite prompt errors out +// instead of blocking the test on stdin. See src/lib/hooks/user-confirmations/_stdinCheckWrapper.ts. +vitest.mock('is-ci', () => ({ default: true })); + +import { MCPInstallCommand } from '../../../../src/commands/mcp/install.js'; +import { testRunCommand } from '../../../../src/lib/command-framework/apify-command.js'; +import { CommandExitCodes } from '../../../../src/lib/consts.js'; +import { useAuthSetup } from '../../../__setup__/hooks/useAuthSetup.js'; +import { useConsoleSpy } from '../../../__setup__/hooks/useConsoleSpy.js'; +import { useTempPath } from '../../../__setup__/hooks/useTempPath.js'; + +const TEST_TOKEN = 'apify_api_TEST_xxxxxxxxxxxxxxxxxxxxxx'; +const OTHER_TEST_TOKEN = 'apify_api_OTHER_yyyyyyyyyyyyyyyyyyyy'; + +const { tmpPath, joinPath, beforeAllCalls, afterAllCalls } = useTempPath('mcp-install'); + +useAuthSetup(); + +const { logMessages } = useConsoleSpy(); + +async function readJson(path: string): Promise> { + return JSON.parse(await readFile(path, 'utf-8')); +} + +beforeAll(beforeAllCalls); +afterAll(afterAllCalls); + +beforeEach(async () => { + // Wipe leftovers from previous tests so each install starts from a clean HOME. + await rm(tmpPath, { recursive: true, force: true }); + await mkdir(tmpPath, { recursive: true }); + // Route every userHomeDir() lookup into the per-suite tmp dir. + vitest.stubEnv('HOME', tmpPath); + // PATH='' guarantees `which('claude'|'codex')` returns null even on dev machines that have those installed. + vitest.stubEnv('PATH', ''); + vitest.stubEnv('APIFY_TOKEN', ''); +}); + +afterEach(() => { + process.exitCode = undefined; + vitest.unstubAllEnvs(); +}); + +describe('apify mcp install', () => { + it('rejects an unknown client name with InvalidInput exit code', async () => { + await testRunCommand(MCPInstallCommand, { args_client: 'foo', flags_token: TEST_TOKEN }); + + expect(process.exitCode).toBe(CommandExitCodes.InvalidInput); + const stderr = logMessages.error.join('\n'); + expect(stderr).toMatch(/Unknown MCP client 'foo'/); + for (const client of ['claude-code', 'cursor', 'vscode', 'codex', 'kiro', 'antigravity']) { + expect(stderr).toContain(client); + } + }); + + it('errors when no token is available anywhere', async () => { + await testRunCommand(MCPInstallCommand, { args_client: 'cursor' }); + + expect(process.exitCode).toBe(CommandExitCodes.MissingAuth); + expect(logMessages.error.join('\n')).toMatch(/not logged in to Apify.*apify login.*--token/s); + }); + + it('cursor: writes mcp.json from --token', async () => { + await testRunCommand(MCPInstallCommand, { args_client: 'cursor', flags_token: TEST_TOKEN }); + + const config = await readJson(joinPath('.cursor', 'mcp.json')); + expect(config).toEqual({ + mcpServers: { + apify: { + url: 'https://mcp.apify.com', + headers: { Authorization: `Bearer ${TEST_TOKEN}` }, + }, + }, + }); + }); + + it('cursor: re-run without --yes in non-TTY exits with "Re-run with --yes"', async () => { + await testRunCommand(MCPInstallCommand, { args_client: 'cursor', flags_token: TEST_TOKEN }); + expect(process.exitCode).toBeUndefined(); + + await testRunCommand(MCPInstallCommand, { args_client: 'cursor', flags_token: OTHER_TEST_TOKEN }); + + expect(process.exitCode).toBe(1); + expect(logMessages.error.join('\n')).toMatch(/already exists.*Re-run with --yes/s); + + // Original token preserved. + const config = await readJson(joinPath('.cursor', 'mcp.json')); + expect((config.mcpServers as { apify: { headers: { Authorization: string } } }).apify.headers.Authorization).toBe( + `Bearer ${TEST_TOKEN}`, + ); + }); + + it('cursor: re-run with --yes overwrites the existing entry', async () => { + await testRunCommand(MCPInstallCommand, { args_client: 'cursor', flags_token: TEST_TOKEN }); + await testRunCommand(MCPInstallCommand, { args_client: 'cursor', flags_token: OTHER_TEST_TOKEN, flags_yes: true }); + + expect(process.exitCode).toBeUndefined(); + const config = await readJson(joinPath('.cursor', 'mcp.json')); + expect((config.mcpServers as { apify: { headers: { Authorization: string } } }).apify.headers.Authorization).toBe( + `Bearer ${OTHER_TEST_TOKEN}`, + ); + }); + + it('cursor: --tools is appended to the URL as a ?tools= query string', async () => { + await testRunCommand(MCPInstallCommand, { + args_client: 'cursor', + flags_token: TEST_TOKEN, + flags_tools: 'search-actors,apify/rag-web-browser', + }); + + const config = await readJson(joinPath('.cursor', 'mcp.json')); + expect((config.mcpServers as { apify: { url: string } }).apify.url).toBe( + 'https://mcp.apify.com?tools=search-actors,apify/rag-web-browser', + ); + }); + + it('cursor: falls back to APIFY_TOKEN env when no --token is given', async () => { + vitest.stubEnv('APIFY_TOKEN', OTHER_TEST_TOKEN); + + await testRunCommand(MCPInstallCommand, { args_client: 'cursor' }); + + const config = await readJson(joinPath('.cursor', 'mcp.json')); + expect((config.mcpServers as { apify: { headers: { Authorization: string } } }).apify.headers.Authorization).toBe( + `Bearer ${OTHER_TEST_TOKEN}`, + ); + }); + + it('antigravity: writes serverUrl (not url)', async () => { + await testRunCommand(MCPInstallCommand, { args_client: 'antigravity', flags_token: TEST_TOKEN }); + + const config = await readJson(joinPath('.gemini', 'antigravity', 'mcp_config.json')); + const entry = (config.mcpServers as { apify: Record }).apify; + expect(entry).toHaveProperty('serverUrl', 'https://mcp.apify.com'); + expect(entry).not.toHaveProperty('url'); + }); + + it('vscode: writes servers.apify with the literal Bearer token (matches other clients)', async () => { + await testRunCommand(MCPInstallCommand, { args_client: 'vscode', flags_token: TEST_TOKEN }); + + const vsCodePath = findExistingPath([ + `${tmpPath}/.config/Code/User/mcp.json`, + `${tmpPath}/Library/Application Support/Code/User/mcp.json`, + `${tmpPath}/AppData/Roaming/Code/User/mcp.json`, + ]); + if (!vsCodePath) throw new Error('expected VS Code mcp.json to exist after install'); + const config = await readJson(vsCodePath); + + expect(config).toEqual({ + servers: { + apify: { + type: 'http', + url: 'https://mcp.apify.com', + headers: { Authorization: `Bearer ${TEST_TOKEN}` }, + }, + }, + }); + }); + + it('claude-code: emits a friendly error with a copy-pastable command (no token leak) when claude is not on PATH', async () => { + await testRunCommand(MCPInstallCommand, { args_client: 'claude-code', flags_token: TEST_TOKEN }); + + expect(process.exitCode).toBe(CommandExitCodes.NotFound); + const stderr = logMessages.error.join('\n'); + expect(stderr).toMatch(/'claude' CLI was not found on PATH/); + // Manual fallback uses a placeholder — we never print the actual token to the terminal. + expect(stderr).toContain( + 'claude mcp add --transport http --scope user apify "https://mcp.apify.com" --header "Authorization: Bearer "', + ); + expect(stderr).not.toContain(TEST_TOKEN); + }); + + it('codex: emits a friendly error with the TOML snippet (no token leak) when codex is not on PATH', async () => { + await testRunCommand(MCPInstallCommand, { args_client: 'codex', flags_token: TEST_TOKEN }); + + expect(process.exitCode).toBe(CommandExitCodes.NotFound); + const stderr = logMessages.error.join('\n'); + expect(stderr).toMatch(/'codex' CLI was not found on PATH/); + expect(stderr).toContain('[mcp_servers.apify]'); + expect(stderr).toContain('url = "https://mcp.apify.com"'); + expect(stderr).toContain('bearer_token_env_var = "APIFY_TOKEN"'); + expect(stderr).toContain('export APIFY_TOKEN='); + expect(stderr).not.toContain(TEST_TOKEN); + }); +}); + +function findExistingPath(candidates: string[]): string | null { + return candidates.find((path) => existsSync(path)) ?? null; +} From 43dbc161e0e7bda0ea171a7fe438b872d90c5535 Mon Sep 17 00:00:00 2001 From: MQ37 Date: Mon, 18 May 2026 11:18:07 +0200 Subject: [PATCH 2/8] refactor(mcp): use 'code --add-mcp' for vscode + add vscode-insiders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch the vscode handler to shell out to 'code --add-mcp ' so VS Code itself owns the config write (preserves JSONC comments, format, and overwrite semantics). Add vscode-insiders as a sibling client using 'code-insiders --add-mcp'. Other file-edit clients (cursor, kiro, antigravity) keep their current handlers — their CLIs either don't expose '--add-mcp' (cursor's wrapper double-evals args breaking JSON quoting) or their --add-mcp behavior isn't suitable for our use case. --- docs/reference.md | 6 +- scripts/reference-template.md | 2 +- src/lib/mcp/clients.ts | 104 ++++++++++++++++++------ test/local/commands/mcp/install.test.ts | 48 +++++------ 4 files changed, 106 insertions(+), 54 deletions(-) diff --git a/docs/reference.md b/docs/reference.md index cdd6715ad..df12aee09 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -1576,7 +1576,7 @@ FLAGS ### MCP -Use these commands to configure the Apify MCP server in your favorite AI client (Claude Code, Cursor, VS Code, Codex CLI, Kiro, Antigravity). +Use these commands to configure the Apify MCP server in your favorite AI client (Claude Code, Cursor, VS Code, VS Code Insiders, Codex CLI, Kiro, Antigravity). @@ -1607,8 +1607,8 @@ USAGE [--url ] [-y] ARGUMENTS - client Target MCP client. One of: claude-code, cursor, vscode, codex, - kiro, antigravity. + client Target MCP client. One of: claude-code, cursor, vscode, + vscode-insiders, codex, kiro, antigravity. FLAGS -t, --token= Apify API token to embed in the config. diff --git a/scripts/reference-template.md b/scripts/reference-template.md index fe3ca5dfe..2809135fa 100644 --- a/scripts/reference-template.md +++ b/scripts/reference-template.md @@ -113,7 +113,7 @@ These commands help you manage scheduled and configured Actor runs. Use them to ### MCP -Use these commands to configure the Apify MCP server in your favorite AI client (Claude Code, Cursor, VS Code, Codex CLI, Kiro, Antigravity). +Use these commands to configure the Apify MCP server in your favorite AI client (Claude Code, Cursor, VS Code, VS Code Insiders, Codex CLI, Kiro, Antigravity). diff --git a/src/lib/mcp/clients.ts b/src/lib/mcp/clients.ts index 18e7e94ca..d41987332 100644 --- a/src/lib/mcp/clients.ts +++ b/src/lib/mcp/clients.ts @@ -12,7 +12,15 @@ import { describeExecaError } from './exec-helpers.js'; import { confirmOverwrite, readJsonConfig, writeJsonConfig } from './file-config.js'; import { maskToken } from './url.js'; -export const SUPPORTED_CLIENTS = ['claude-code', 'cursor', 'vscode', 'codex', 'kiro', 'antigravity'] as const; +export const SUPPORTED_CLIENTS = [ + 'claude-code', + 'cursor', + 'vscode', + 'vscode-insiders', + 'codex', + 'kiro', + 'antigravity', +] as const; export type ClientName = (typeof SUPPORTED_CLIENTS)[number]; @@ -181,39 +189,88 @@ const cursorHandler: ClientHandler = async ({ url, token, yes }) => { }); }; -function vscodeConfigPath(): string { - switch (process.platform) { - case 'darwin': - return join(userHomeDir(), 'Library', 'Application Support', 'Code', 'User', 'mcp.json'); - case 'win32': { - const appData = process.env.APPDATA ?? join(userHomeDir(), 'AppData', 'Roaming'); - return join(appData, 'Code', 'User', 'mcp.json'); - } - default: - return join(userHomeDir(), '.config', 'Code', 'User', 'mcp.json'); +/** + * Install via the client's own '--add-mcp ' CLI. The client owns the config format, + * comments, and overwrite semantics — we just shell out and let it handle the rest. + */ +async function addMcpViaCli({ + binary, + clientLabel, + docsUrl, + url, + token, +}: { + binary: string; + clientLabel: string; + docsUrl: string; + url: string; + token: string; +}): Promise { + const bin = await which(binary, { nothrow: true }); + + if (!bin) { + const placeholderJson = JSON.stringify({ + name: SERVER_KEY, + type: 'http', + url, + headers: { Authorization: 'Bearer ' }, + }); + error({ + message: `The '${binary}' CLI was not found on PATH. Install ${clientLabel} and re-run, or add the server manually:\n\n ${binary} --add-mcp '${placeholderJson}'`, + }); + process.exitCode = CommandExitCodes.NotFound; + return; } -} -const vscodeHandler: ClientHandler = async ({ url, token, yes }) => { - const filePath = vscodeConfigPath(); - const serverEntry = { + const serverJson = JSON.stringify({ + name: SERVER_KEY, type: 'http', url, headers: { Authorization: `Bearer ${token}` }, - }; + }); + const maskedJson = JSON.stringify({ + name: SERVER_KEY, + type: 'http', + url, + headers: { Authorization: `Bearer ${maskToken(token)}` }, + }); - const wrote = await mergeServerEntry({ filePath, topLevelKey: 'servers', serverEntry, yes }); - if (!wrote) return; + run({ message: `${binary} --add-mcp '${maskedJson}'` }); + + try { + await execa(binary, ['--add-mcp', serverJson], { stdio: ['ignore', 'ignore', 'inherit'] }); + } catch (err) { + error({ message: `Failed to add the MCP server via ${clientLabel}: ${describeExecaError(err, binary)}` }); + process.exitCode = CommandExitCodes.RunFailed; + return; + } printResult({ - clientLabel: 'VS Code', + clientLabel, serverUrl: url, - authDescription: `Bearer ${maskToken(token)}`, - configPath: filePath, - nextSteps: ['Restart VS Code.', 'Open the Chat view and confirm Apify tools appear.'], + authDescription: `Bearer ${maskToken(token)} (stored by ${clientLabel})`, + nextSteps: [`Restart ${clientLabel} if it's running so the new entry is picked up.`], + docsUrl, + }); +} + +const vscodeHandler: ClientHandler = async ({ url, token }) => + addMcpViaCli({ + binary: 'code', + clientLabel: 'VS Code', + docsUrl: 'https://code.visualstudio.com/docs/copilot/customization/mcp-servers', + url, + token, + }); + +const vscodeInsidersHandler: ClientHandler = async ({ url, token }) => + addMcpViaCli({ + binary: 'code-insiders', + clientLabel: 'VS Code Insiders', docsUrl: 'https://code.visualstudio.com/docs/copilot/customization/mcp-servers', + url, + token, }); -}; const codexHandler: ClientHandler = async ({ url }) => { const codexBin = await which('codex', { nothrow: true }); @@ -303,6 +360,7 @@ const HANDLERS: Record = { 'claude-code': claudeCodeHandler, cursor: cursorHandler, vscode: vscodeHandler, + 'vscode-insiders': vscodeInsidersHandler, codex: codexHandler, kiro: kiroHandler, antigravity: antigravityHandler, diff --git a/test/local/commands/mcp/install.test.ts b/test/local/commands/mcp/install.test.ts index 241854299..bba14e1e8 100644 --- a/test/local/commands/mcp/install.test.ts +++ b/test/local/commands/mcp/install.test.ts @@ -1,4 +1,3 @@ -import { existsSync } from 'node:fs'; import { mkdir, readFile, rm } from 'node:fs/promises'; import process from 'node:process'; @@ -52,7 +51,7 @@ describe('apify mcp install', () => { expect(process.exitCode).toBe(CommandExitCodes.InvalidInput); const stderr = logMessages.error.join('\n'); expect(stderr).toMatch(/Unknown MCP client 'foo'/); - for (const client of ['claude-code', 'cursor', 'vscode', 'codex', 'kiro', 'antigravity']) { + for (const client of ['claude-code', 'cursor', 'vscode', 'vscode-insiders', 'codex', 'kiro', 'antigravity']) { expect(stderr).toContain(client); } }); @@ -138,27 +137,26 @@ describe('apify mcp install', () => { expect(entry).not.toHaveProperty('url'); }); - it('vscode: writes servers.apify with the literal Bearer token (matches other clients)', async () => { - await testRunCommand(MCPInstallCommand, { args_client: 'vscode', flags_token: TEST_TOKEN }); - - const vsCodePath = findExistingPath([ - `${tmpPath}/.config/Code/User/mcp.json`, - `${tmpPath}/Library/Application Support/Code/User/mcp.json`, - `${tmpPath}/AppData/Roaming/Code/User/mcp.json`, - ]); - if (!vsCodePath) throw new Error('expected VS Code mcp.json to exist after install'); - const config = await readJson(vsCodePath); - - expect(config).toEqual({ - servers: { - apify: { - type: 'http', - url: 'https://mcp.apify.com', - headers: { Authorization: `Bearer ${TEST_TOKEN}` }, - }, - }, - }); - }); + // vscode and vscode-insiders shell out to ' --add-mcp '; with PATH='' the binary is missing. + describe.each([ + { client: 'vscode', binary: 'code', label: 'VS Code' }, + { client: 'vscode-insiders', binary: 'code-insiders', label: 'VS Code Insiders' }, + ])( + '$client: friendly error with copy-pastable command (no token leak) when binary is not on PATH', + ({ client, binary, label }) => { + it('emits the right snippet and exits with NotFound', async () => { + await testRunCommand(MCPInstallCommand, { args_client: client, flags_token: TEST_TOKEN }); + + expect(process.exitCode).toBe(CommandExitCodes.NotFound); + const stderr = logMessages.error.join('\n'); + expect(stderr).toContain(`'${binary}' CLI was not found on PATH`); + expect(stderr).toContain(`Install ${label}`); + expect(stderr).toContain(`${binary} --add-mcp '`); + expect(stderr).toContain('"Authorization":"Bearer "'); + expect(stderr).not.toContain(TEST_TOKEN); + }); + }, + ); it('claude-code: emits a friendly error with a copy-pastable command (no token leak) when claude is not on PATH', async () => { await testRunCommand(MCPInstallCommand, { args_client: 'claude-code', flags_token: TEST_TOKEN }); @@ -186,7 +184,3 @@ describe('apify mcp install', () => { expect(stderr).not.toContain(TEST_TOKEN); }); }); - -function findExistingPath(candidates: string[]): string | null { - return candidates.find((path) => existsSync(path)) ?? null; -} From a34d8d9151172b6053d06d98c737f9d842d427ba Mon Sep 17 00:00:00 2001 From: MQ37 Date: Mon, 18 May 2026 11:25:36 +0200 Subject: [PATCH 3/8] refactor(mcp): drop 'Docs:' line from install success output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a successful install, a link to the third-party client's MCP docs is noise. The user just configured the server — they don't need to look up how the client handles MCP. --- src/lib/mcp/clients.ts | 26 ++------------------------ 1 file changed, 2 insertions(+), 24 deletions(-) diff --git a/src/lib/mcp/clients.ts b/src/lib/mcp/clients.ts index d41987332..24f67aef3 100644 --- a/src/lib/mcp/clients.ts +++ b/src/lib/mcp/clients.ts @@ -44,7 +44,6 @@ interface InstallResult { authDescription: string; configPath?: string; nextSteps: string[]; - docsUrl: string; } function printResult(result: InstallResult): void { @@ -62,7 +61,6 @@ function printResult(result: InstallResult): void { for (const [index, step] of result.nextSteps.entries()) { lines.push(` ${index + 1}. ${step}`); } - lines.push('', ` ${chalk.yellow('Docs:')} ${result.docsUrl}`); simpleLog({ message: lines.join('\n') }); } @@ -164,7 +162,6 @@ const claudeCodeHandler: ClientHandler = async ({ url, token }) => { serverUrl: url, authDescription: `Bearer ${maskToken(token)} (stored by Claude Code)`, nextSteps: [`Run 'claude mcp list' to confirm the apify server is registered.`], - docsUrl: 'https://docs.anthropic.com/en/docs/claude-code/mcp', }); }; @@ -185,7 +182,6 @@ const cursorHandler: ClientHandler = async ({ url, token, yes }) => { 'Open Cursor Settings → Features → Model Context Protocol.', `Verify '${SERVER_KEY}' is listed and enabled.`, ], - docsUrl: 'https://cursor.com/docs/mcp', }); }; @@ -196,13 +192,11 @@ const cursorHandler: ClientHandler = async ({ url, token, yes }) => { async function addMcpViaCli({ binary, clientLabel, - docsUrl, url, token, }: { binary: string; clientLabel: string; - docsUrl: string; url: string; token: string; }): Promise { @@ -250,27 +244,14 @@ async function addMcpViaCli({ serverUrl: url, authDescription: `Bearer ${maskToken(token)} (stored by ${clientLabel})`, nextSteps: [`Restart ${clientLabel} if it's running so the new entry is picked up.`], - docsUrl, }); } const vscodeHandler: ClientHandler = async ({ url, token }) => - addMcpViaCli({ - binary: 'code', - clientLabel: 'VS Code', - docsUrl: 'https://code.visualstudio.com/docs/copilot/customization/mcp-servers', - url, - token, - }); + addMcpViaCli({ binary: 'code', clientLabel: 'VS Code', url, token }); const vscodeInsidersHandler: ClientHandler = async ({ url, token }) => - addMcpViaCli({ - binary: 'code-insiders', - clientLabel: 'VS Code Insiders', - docsUrl: 'https://code.visualstudio.com/docs/copilot/customization/mcp-servers', - url, - token, - }); + addMcpViaCli({ binary: 'code-insiders', clientLabel: 'VS Code Insiders', url, token }); const codexHandler: ClientHandler = async ({ url }) => { const codexBin = await which('codex', { nothrow: true }); @@ -309,7 +290,6 @@ const codexHandler: ClientHandler = async ({ url }) => { `Run 'codex mcp' to list configured servers.`, `Start Codex and run /mcp to confirm Apify is connected.`, ], - docsUrl: 'https://developers.openai.com/codex/mcp', }); }; @@ -331,7 +311,6 @@ const kiroHandler: ClientHandler = async ({ url, token, yes }) => { authDescription: `Bearer ${maskToken(token)}`, configPath: filePath, nextSteps: ['Restart Kiro.', 'Open Kiro → MCP Servers and verify the apify entry is enabled.'], - docsUrl: 'https://kiro.dev/docs/mcp/configuration/', }); }; @@ -352,7 +331,6 @@ const antigravityHandler: ClientHandler = async ({ url, token, yes }) => { 'Restart Antigravity or reload the agent panel.', 'Open the MCP Store to confirm the apify entry is loaded.', ], - docsUrl: 'https://antigravity.google/docs/mcp', }); }; From e9e19163fbc366a2685387d8964e38906c04ccf2 Mon Sep 17 00:00:00 2001 From: MQ37 Date: Mon, 18 May 2026 11:58:34 +0200 Subject: [PATCH 4/8] feat(mcp): preserve JSONC comments and formatting on file edits Switch file-edit clients (cursor, kiro, antigravity) from JSON.parse + JSON.stringify to jsonc-parser's surgical modify()/applyEdits() API. The library patches the source text in place: comments, trailing commas, indentation, and unrelated keys all survive untouched. Adds a regression test that pre-seeds a hand-edited JSONC file with comments and asserts they survive a re-install. --- package.json | 1 + pnpm-lock.yaml | 8 +++ src/lib/mcp/clients.ts | 60 ++--------------- src/lib/mcp/file-config.ts | 88 +++++++++++++++---------- test/local/commands/mcp/install.test.ts | 30 ++++++++- 5 files changed, 96 insertions(+), 91 deletions(-) diff --git a/package.json b/package.json index 899b8b644..28798370c 100644 --- a/package.json +++ b/package.json @@ -105,6 +105,7 @@ "jju": "~1.4.0", "js-levenshtein": "^1.1.6", "json-schema-to-typescript": "^15.0.4", + "jsonc-parser": "3.3.1", "mime": "~4.1.0", "open": "~11.0.0", "rimraf": "~6.1.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0195362c1..bf815422d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -131,6 +131,9 @@ importers: json-schema-to-typescript: specifier: ^15.0.4 version: 15.0.4 + jsonc-parser: + specifier: 3.3.1 + version: 3.3.1 mime: specifier: ~4.1.0 version: 4.1.0 @@ -6110,6 +6113,9 @@ packages: engines: {node: '>=6'} hasBin: true + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + jsonfile@6.2.0: resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} @@ -15951,6 +15957,8 @@ snapshots: json5@2.2.3: {} + jsonc-parser@3.3.1: {} + jsonfile@6.2.0: dependencies: universalify: 2.0.1 diff --git a/src/lib/mcp/clients.ts b/src/lib/mcp/clients.ts index 24f67aef3..d87bc185a 100644 --- a/src/lib/mcp/clients.ts +++ b/src/lib/mcp/clients.ts @@ -9,7 +9,7 @@ import { CommandExitCodes } from '../consts.js'; import { error, run, simpleLog, success } from '../outputs.js'; import { tildify, userHomeDir } from '../utils.js'; import { describeExecaError } from './exec-helpers.js'; -import { confirmOverwrite, readJsonConfig, writeJsonConfig } from './file-config.js'; +import { mergeServerEntry } from './file-config.js'; import { maskToken } from './url.js'; export const SUPPORTED_CLIENTS = [ @@ -65,58 +65,6 @@ function printResult(result: InstallResult): void { simpleLog({ message: lines.join('\n') }); } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -/** - * Load the JSON config and confirm an overwrite if an entry under SERVER_KEY already exists. - * Returns null if the user declined; otherwise returns the parsed config and the (possibly empty) servers map at topLevelKey. - */ -async function readConfigForOverwrite({ - filePath, - topLevelKey, - yes, -}: { - filePath: string; - topLevelKey: string; - yes: boolean; -}): Promise<{ config: Record; servers: Record } | null> { - const config = await readJsonConfig(filePath); - const servers = isRecord(config[topLevelKey]) ? (config[topLevelKey] as Record) : {}; - - if (SERVER_KEY in servers) { - const ok = await confirmOverwrite({ filePath, entryKey: SERVER_KEY, yes }); - if (!ok) { - simpleLog({ message: 'No changes written.' }); - return null; - } - } - - return { config, servers }; -} - -/** Convenience for the common shape: read+confirm, write a single server entry, return whether the write happened. */ -async function mergeServerEntry({ - filePath, - topLevelKey, - serverEntry, - yes, -}: { - filePath: string; - topLevelKey: string; - serverEntry: Record; - yes: boolean; -}): Promise { - const result = await readConfigForOverwrite({ filePath, topLevelKey, yes }); - if (!result) return false; - - result.servers[SERVER_KEY] = serverEntry; - result.config[topLevelKey] = result.servers; - await writeJsonConfig(filePath, result.config); - return true; -} - const claudeCodeHandler: ClientHandler = async ({ url, token }) => { const claudeBin = await which('claude', { nothrow: true }); @@ -169,7 +117,7 @@ const cursorHandler: ClientHandler = async ({ url, token, yes }) => { const filePath = join(userHomeDir(), '.cursor', 'mcp.json'); const serverEntry = { url, headers: { Authorization: `Bearer ${token}` } }; - const wrote = await mergeServerEntry({ filePath, topLevelKey: 'mcpServers', serverEntry, yes }); + const wrote = await mergeServerEntry({ filePath, topLevelKey: 'mcpServers', entryKey: SERVER_KEY, serverEntry, yes }); if (!wrote) return; printResult({ @@ -302,7 +250,7 @@ const kiroHandler: ClientHandler = async ({ url, token, yes }) => { autoApprove: [] as string[], }; - const wrote = await mergeServerEntry({ filePath, topLevelKey: 'mcpServers', serverEntry, yes }); + const wrote = await mergeServerEntry({ filePath, topLevelKey: 'mcpServers', entryKey: SERVER_KEY, serverEntry, yes }); if (!wrote) return; printResult({ @@ -319,7 +267,7 @@ const antigravityHandler: ClientHandler = async ({ url, token, yes }) => { // Antigravity uses 'serverUrl' (not 'url'); see https://antigravity.google/docs/mcp. const serverEntry = { serverUrl: url, headers: { Authorization: `Bearer ${token}` } }; - const wrote = await mergeServerEntry({ filePath, topLevelKey: 'mcpServers', serverEntry, yes }); + const wrote = await mergeServerEntry({ filePath, topLevelKey: 'mcpServers', entryKey: SERVER_KEY, serverEntry, yes }); if (!wrote) return; printResult({ diff --git a/src/lib/mcp/file-config.ts b/src/lib/mcp/file-config.ts index 73164debd..8dcf7c5fb 100644 --- a/src/lib/mcp/file-config.ts +++ b/src/lib/mcp/file-config.ts @@ -2,49 +2,19 @@ import { existsSync } from 'node:fs'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { dirname } from 'node:path'; +import { applyEdits, findNodeAtLocation, modify, parseTree } from 'jsonc-parser'; + import { useYesNoConfirm } from '../hooks/user-confirmations/useYesNoConfirm.js'; +import { simpleLog } from '../outputs.js'; import { tildify } from '../utils.js'; -// Client config files are user-editable, so we cannot rely on schema invariants. Surface a readable error instead of letting SyntaxError bubble up. -export async function readJsonConfig(filePath: string): Promise> { - if (!existsSync(filePath)) return {}; - - const raw = await readFile(filePath, 'utf-8'); - const trimmed = raw.trim(); - if (!trimmed) return {}; - - let parsed: unknown; - try { - parsed = JSON.parse(trimmed); - } catch (err) { - if (err instanceof SyntaxError) { - // VS Code's mcp.json supports JSONC (comments, trailing commas); plain JSON.parse cannot read those. - const hint = /\/\/|\/\*/.test(trimmed) - ? ' The file appears to contain comments (JSONC); add the apify entry manually.' - : ''; - throw new Error(`Cannot parse ${tildify(filePath)}: ${err.message}.${hint}`); - } - throw err; - } - - if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new Error(`${tildify(filePath)} is not a JSON object. Fix or remove the file and try again.`); - } - return parsed as Record; -} - -export async function writeJsonConfig(filePath: string, contents: Record): Promise { - await mkdir(dirname(filePath), { recursive: true }); - await writeFile(filePath, `${JSON.stringify(contents, null, 2)}\n`, 'utf-8'); -} - interface ConfirmOverwriteOptions { filePath: string; entryKey: string; yes: boolean; } -export async function confirmOverwrite({ filePath, entryKey, yes }: ConfirmOverwriteOptions): Promise { +async function confirmOverwrite({ filePath, entryKey, yes }: ConfirmOverwriteOptions): Promise { // Skip the existing-entry preview — it contains a bearer token we should not reprint. return useYesNoConfirm({ message: `A server entry named '${entryKey}' already exists in ${tildify(filePath)}. Overwrite it?`, @@ -53,3 +23,53 @@ export async function confirmOverwrite({ filePath, entryKey, yes }: ConfirmOverw errorMessageForStdin: `An '${entryKey}' entry already exists in ${tildify(filePath)}. Re-run with --yes to overwrite.`, }); } + +/** + * Surgically insert or replace one entry in a JSONC config file. + * Uses jsonc-parser's edit API, which patches the source text in place — comments, indentation, + * trailing commas, and unrelated keys all survive untouched. Falls back gracefully when the file + * is missing or empty (a fresh object is created). + * + * Returns true if the file was written, false if the user declined the overwrite. + */ +export async function mergeServerEntry({ + filePath, + topLevelKey, + entryKey, + serverEntry, + yes, +}: { + filePath: string; + topLevelKey: string; + entryKey: string; + serverEntry: Record; + yes: boolean; +}): Promise { + const text = existsSync(filePath) ? await readFile(filePath, 'utf-8') : ''; + const root = text.trim() ? parseTree(text) : undefined; + + if (root) { + const topLevelNode = findNodeAtLocation(root, [topLevelKey]); + if (topLevelNode && topLevelNode.type !== 'object') { + throw new Error( + `Cannot install: '${topLevelKey}' in ${tildify(filePath)} is not a JSON object. Fix the file manually and re-run.`, + ); + } + if (findNodeAtLocation(root, [topLevelKey, entryKey])) { + const ok = await confirmOverwrite({ filePath, entryKey, yes }); + if (!ok) { + simpleLog({ message: 'No changes written.' }); + return false; + } + } + } + + const edits = modify(text, [topLevelKey, entryKey], serverEntry, { + formattingOptions: { tabSize: 2, insertSpaces: true }, + }); + const newText = applyEdits(text, edits); + + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(filePath, newText, 'utf-8'); + return true; +} diff --git a/test/local/commands/mcp/install.test.ts b/test/local/commands/mcp/install.test.ts index bba14e1e8..c1e61010d 100644 --- a/test/local/commands/mcp/install.test.ts +++ b/test/local/commands/mcp/install.test.ts @@ -1,4 +1,4 @@ -import { mkdir, readFile, rm } from 'node:fs/promises'; +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import process from 'node:process'; // Force `useYesNoConfirm` down its non-interactive path so the overwrite prompt errors out @@ -117,6 +117,34 @@ describe('apify mcp install', () => { ); }); + it('cursor: preserves comments and other servers when re-installing (JSONC round-trip)', async () => { + // Pre-seed a hand-edited config with comments, a trailing comma, and an unrelated server. + const cursorPath = joinPath('.cursor', 'mcp.json'); + await mkdir(joinPath('.cursor'), { recursive: true }); + const originalJsonc = [ + '// User-added top-of-file comment', + '{', + '\t"mcpServers": {', + '\t\t"github": {', + '\t\t\t// PAT expires 2026-01', + '\t\t\t"url": "https://api.githubcopilot.com/mcp",', + '\t\t\t"headers": { "Authorization": "Bearer github_pat_XYZ" }', + '\t\t},', + '\t},', + '}', + '', + ].join('\n'); + await writeFile(cursorPath, originalJsonc, 'utf-8'); + + await testRunCommand(MCPInstallCommand, { args_client: 'cursor', flags_token: TEST_TOKEN }); + + const updated = await readFile(cursorPath, 'utf-8'); + expect(updated).toContain('// User-added top-of-file comment'); + expect(updated).toContain('// PAT expires 2026-01'); + expect(updated).toContain('github_pat_XYZ'); + expect(updated).toContain(`Bearer ${TEST_TOKEN}`); + }); + it('cursor: falls back to APIFY_TOKEN env when no --token is given', async () => { vitest.stubEnv('APIFY_TOKEN', OTHER_TEST_TOKEN); From a43309008cfcbe06cd612092930d072e029b4b3e Mon Sep 17 00:00:00 2001 From: MQ37 Date: Mon, 18 May 2026 12:05:45 +0200 Subject: [PATCH 5/8] refactor(mcp): drop 'Next steps' from install success output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep only Server URL, Auth, and Config path. The per-client step lists were noise — users already know how to restart their editor. --- src/lib/mcp/clients.ts | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/src/lib/mcp/clients.ts b/src/lib/mcp/clients.ts index d87bc185a..2fd6dc09d 100644 --- a/src/lib/mcp/clients.ts +++ b/src/lib/mcp/clients.ts @@ -43,7 +43,6 @@ interface InstallResult { serverUrl: string; authDescription: string; configPath?: string; - nextSteps: string[]; } function printResult(result: InstallResult): void { @@ -57,10 +56,6 @@ function printResult(result: InstallResult): void { if (result.configPath) { lines.push(` ${chalk.yellow('Config:')} ${tildify(result.configPath)}`); } - lines.push('', ` ${chalk.yellow('Next steps:')}`); - for (const [index, step] of result.nextSteps.entries()) { - lines.push(` ${index + 1}. ${step}`); - } simpleLog({ message: lines.join('\n') }); } @@ -109,7 +104,6 @@ const claudeCodeHandler: ClientHandler = async ({ url, token }) => { clientLabel: 'Claude Code', serverUrl: url, authDescription: `Bearer ${maskToken(token)} (stored by Claude Code)`, - nextSteps: [`Run 'claude mcp list' to confirm the apify server is registered.`], }); }; @@ -125,11 +119,6 @@ const cursorHandler: ClientHandler = async ({ url, token, yes }) => { serverUrl: url, authDescription: `Bearer ${maskToken(token)}`, configPath: filePath, - nextSteps: [ - 'Restart Cursor.', - 'Open Cursor Settings → Features → Model Context Protocol.', - `Verify '${SERVER_KEY}' is listed and enabled.`, - ], }); }; @@ -191,7 +180,6 @@ async function addMcpViaCli({ clientLabel, serverUrl: url, authDescription: `Bearer ${maskToken(token)} (stored by ${clientLabel})`, - nextSteps: [`Restart ${clientLabel} if it's running so the new entry is picked up.`], }); } @@ -233,11 +221,6 @@ const codexHandler: ClientHandler = async ({ url }) => { serverUrl: url, authDescription: `Bearer token from APIFY_TOKEN environment variable`, configPath: tomlPath, - nextSteps: [ - `Export your Apify token in your shell rc: export APIFY_TOKEN=`, - `Run 'codex mcp' to list configured servers.`, - `Start Codex and run /mcp to confirm Apify is connected.`, - ], }); }; @@ -258,7 +241,6 @@ const kiroHandler: ClientHandler = async ({ url, token, yes }) => { serverUrl: url, authDescription: `Bearer ${maskToken(token)}`, configPath: filePath, - nextSteps: ['Restart Kiro.', 'Open Kiro → MCP Servers and verify the apify entry is enabled.'], }); }; @@ -275,10 +257,6 @@ const antigravityHandler: ClientHandler = async ({ url, token, yes }) => { serverUrl: url, authDescription: `Bearer ${maskToken(token)}`, configPath: filePath, - nextSteps: [ - 'Restart Antigravity or reload the agent panel.', - 'Open the MCP Store to confirm the apify entry is loaded.', - ], }); }; From d7cc312ccf4236756094ba62b90369ff003d9124 Mon Sep 17 00:00:00 2001 From: MQ37 Date: Mon, 18 May 2026 13:22:47 +0200 Subject: [PATCH 6/8] docs(mcp): apply szaganek's copy tweaks from PR review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Use these commands to configure the Apify MCP server in your AI client." — drop "favorite", drop parenthetical client list from the section intro. Colon-separate client names in the index description, drop parenthetical '(or merges)' and '(forwarded as…)' from the install command text. --- docs/reference.md | 16 ++++++++-------- scripts/reference-template.md | 2 +- src/commands/mcp/_index.ts | 2 +- src/commands/mcp/install.ts | 4 ++-- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/reference.md b/docs/reference.md index df12aee09..aa5981f8b 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -1576,7 +1576,7 @@ FLAGS ### MCP -Use these commands to configure the Apify MCP server in your favorite AI client (Claude Code, Cursor, VS Code, VS Code Insiders, Codex CLI, Kiro, Antigravity). +Use these commands to configure the Apify MCP server in your AI client. @@ -1584,12 +1584,12 @@ Use these commands to configure the Apify MCP server in your favorite AI client ```sh DESCRIPTION - Configure the Apify MCP server in your favorite AI client (Claude Code, - Cursor, VS Code, ...). + Configure the Apify MCP server in your AI client: Claude Code, Cursor, VS + Code, Codex CLI, Kiro, or Antigravity. SUBCOMMANDS mcp install Configure a local MCP client to use the Apify MCP - server. Writes (or merges) a server entry named 'apify' into the + server. Writes or merges a server entry named 'apify' into the client's config file, or runs the client's own 'mcp add' command when available. ``` @@ -1598,9 +1598,9 @@ SUBCOMMANDS ```sh DESCRIPTION - Configure a local MCP client to use the Apify MCP server. Writes (or merges) a - server entry named 'apify' into the client's config file, or runs the - client's own 'mcp add' command when available. + Configure a local MCP client to use the Apify MCP server. Writes or merges a + server entry named 'apify' into the client's config file, or runs the client's + own 'mcp add' command when available. USAGE $ apify mcp install [-t ] [--tools ] @@ -1614,7 +1614,7 @@ FLAGS -t, --token= Apify API token to embed in the config. Defaults to the token from 'apify login'. --tools= Comma-separated tool IDs or Actor full names - to expose (forwarded as a '?tools=' query parameter). + to expose. Forwarded as a '?tools=' query parameter. --url= Apify MCP server URL. -y, --yes Overwrite an existing 'apify' entry without prompting. diff --git a/scripts/reference-template.md b/scripts/reference-template.md index 2809135fa..675eecb8f 100644 --- a/scripts/reference-template.md +++ b/scripts/reference-template.md @@ -113,7 +113,7 @@ These commands help you manage scheduled and configured Actor runs. Use them to ### MCP -Use these commands to configure the Apify MCP server in your favorite AI client (Claude Code, Cursor, VS Code, VS Code Insiders, Codex CLI, Kiro, Antigravity). +Use these commands to configure the Apify MCP server in your AI client. diff --git a/src/commands/mcp/_index.ts b/src/commands/mcp/_index.ts index 900bf35fc..60ec422e7 100644 --- a/src/commands/mcp/_index.ts +++ b/src/commands/mcp/_index.ts @@ -4,7 +4,7 @@ import { MCPInstallCommand } from './install.js'; export class MCPIndexCommand extends ApifyCommand { static override name = 'mcp' as const; - static override description = `Configure the Apify MCP server in your favorite AI client (Claude Code, Cursor, VS Code, ...).`; + static override description = `Configure the Apify MCP server in your AI client: Claude Code, Cursor, VS Code, Codex CLI, Kiro, or Antigravity.`; static override group = 'MCP'; diff --git a/src/commands/mcp/install.ts b/src/commands/mcp/install.ts index 81bcfe1da..721e41c85 100644 --- a/src/commands/mcp/install.ts +++ b/src/commands/mcp/install.ts @@ -12,7 +12,7 @@ import { error } from '../../lib/outputs.js'; export class MCPInstallCommand extends ApifyCommand { static override name = 'install' as const; - static override description = `Configure a local MCP client to use the Apify MCP server. Writes (or merges) a server entry named 'apify' into the client's config file, or runs the client's own 'mcp add' command when available.`; + static override description = `Configure a local MCP client to use the Apify MCP server. Writes or merges a server entry named 'apify' into the client's config file, or runs the client's own 'mcp add' command when available.`; static override group = 'MCP'; @@ -60,7 +60,7 @@ export class MCPInstallCommand extends ApifyCommand { default: DEFAULT_MCP_URL, }), tools: Flags.string({ - description: `Comma-separated tool IDs or Actor full names to expose (forwarded as a '?tools=' query parameter).`, + description: `Comma-separated tool IDs or Actor full names to expose. Forwarded as a '?tools=' query parameter.`, }), }; From bd3acb81de33748f401e4964762035d094edccd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kopeck=C3=BD?= Date: Tue, 19 May 2026 22:28:59 +0200 Subject: [PATCH 7/8] Update src/commands/mcp/install.ts Co-authored-by: Vlad Frangu --- src/commands/mcp/install.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/mcp/install.ts b/src/commands/mcp/install.ts index 721e41c85..9a1a50363 100644 --- a/src/commands/mcp/install.ts +++ b/src/commands/mcp/install.ts @@ -53,7 +53,7 @@ export class MCPInstallCommand extends ApifyCommand { ...YesFlag(`Overwrite an existing 'apify' entry without prompting.`), token: Flags.string({ char: 't', - description: `Apify API token to embed in the config. Defaults to the token from 'apify login'.`, + description: `Apify API token to embed in the config. Defaults to the token from 'apify auth token'.`, }), url: Flags.string({ description: 'Apify MCP server URL.', From c1e65e91be5e109b5b3e410e3cf29df42aa2be61 Mon Sep 17 00:00:00 2001 From: MQ37 Date: Tue, 19 May 2026 22:36:14 +0200 Subject: [PATCH 8/8] test(mcp): move install JSONC fixture to test/__setup__/fixtures/ Per PR review (#1145): hoist the inline 'originalJsonc' literal out of install.test.ts into test/__setup__/fixtures/mcp-install-fixtures.ts so similar fixtures land alongside the existing mock-openapi-spec. --- .../fixtures/mcp-install-fixtures.ts | 19 +++++++++++++++++++ test/local/commands/mcp/install.test.ts | 16 ++-------------- 2 files changed, 21 insertions(+), 14 deletions(-) create mode 100644 test/__setup__/fixtures/mcp-install-fixtures.ts diff --git a/test/__setup__/fixtures/mcp-install-fixtures.ts b/test/__setup__/fixtures/mcp-install-fixtures.ts new file mode 100644 index 000000000..d07a35cae --- /dev/null +++ b/test/__setup__/fixtures/mcp-install-fixtures.ts @@ -0,0 +1,19 @@ +/** + * Pre-seeded Cursor `mcp.json` with a top-of-file line comment, an inline + * comment, a trailing comma, and an unrelated server entry — used to verify + * that `apify mcp install` round-trips JSONC without clobbering anything the + * user typed by hand. + */ +export const CURSOR_MCP_JSONC_WITH_COMMENTS = [ + '// User-added top-of-file comment', + '{', + '\t"mcpServers": {', + '\t\t"github": {', + '\t\t\t// PAT expires 2026-01', + '\t\t\t"url": "https://api.githubcopilot.com/mcp",', + '\t\t\t"headers": { "Authorization": "Bearer github_pat_XYZ" }', + '\t\t},', + '\t},', + '}', + '', +].join('\n'); diff --git a/test/local/commands/mcp/install.test.ts b/test/local/commands/mcp/install.test.ts index c1e61010d..e84e943b4 100644 --- a/test/local/commands/mcp/install.test.ts +++ b/test/local/commands/mcp/install.test.ts @@ -8,6 +8,7 @@ vitest.mock('is-ci', () => ({ default: true })); import { MCPInstallCommand } from '../../../../src/commands/mcp/install.js'; import { testRunCommand } from '../../../../src/lib/command-framework/apify-command.js'; import { CommandExitCodes } from '../../../../src/lib/consts.js'; +import { CURSOR_MCP_JSONC_WITH_COMMENTS } from '../../../__setup__/fixtures/mcp-install-fixtures.js'; import { useAuthSetup } from '../../../__setup__/hooks/useAuthSetup.js'; import { useConsoleSpy } from '../../../__setup__/hooks/useConsoleSpy.js'; import { useTempPath } from '../../../__setup__/hooks/useTempPath.js'; @@ -121,20 +122,7 @@ describe('apify mcp install', () => { // Pre-seed a hand-edited config with comments, a trailing comma, and an unrelated server. const cursorPath = joinPath('.cursor', 'mcp.json'); await mkdir(joinPath('.cursor'), { recursive: true }); - const originalJsonc = [ - '// User-added top-of-file comment', - '{', - '\t"mcpServers": {', - '\t\t"github": {', - '\t\t\t// PAT expires 2026-01', - '\t\t\t"url": "https://api.githubcopilot.com/mcp",', - '\t\t\t"headers": { "Authorization": "Bearer github_pat_XYZ" }', - '\t\t},', - '\t},', - '}', - '', - ].join('\n'); - await writeFile(cursorPath, originalJsonc, 'utf-8'); + await writeFile(cursorPath, CURSOR_MCP_JSONC_WITH_COMMENTS, 'utf-8'); await testRunCommand(MCPInstallCommand, { args_client: 'cursor', flags_token: TEST_TOKEN });