From 25f6250499b51b16f19fdde6a4a99542bcd0e10a Mon Sep 17 00:00:00 2001 From: MQ37 Date: Mon, 18 May 2026 09:16:56 +0200 Subject: [PATCH 01/15] 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 02/15] 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 03/15] 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 04/15] 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 05/15] 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 06/15] 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 07/15] 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 08/15] 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 }); From d108cde302af9538dd3a19a4993b47c0ac7af948 Mon Sep 17 00:00:00 2001 From: MQ37 Date: Mon, 25 May 2026 10:29:59 +0200 Subject: [PATCH 09/15] test(mcp): move cursor JSONC fixture into a real .jsonc file --- .../fixtures/mcp-install-fixtures.jsonc | 10 ++++++++++ test/__setup__/fixtures/mcp-install-fixtures.ts | 16 +++------------- test/local/commands/mcp/install.test.ts | 4 ++-- 3 files changed, 15 insertions(+), 15 deletions(-) create mode 100644 test/__setup__/fixtures/mcp-install-fixtures.jsonc diff --git a/test/__setup__/fixtures/mcp-install-fixtures.jsonc b/test/__setup__/fixtures/mcp-install-fixtures.jsonc new file mode 100644 index 000000000..bcc934379 --- /dev/null +++ b/test/__setup__/fixtures/mcp-install-fixtures.jsonc @@ -0,0 +1,10 @@ +// User-added top-of-file comment +{ + "mcpServers": { + "github": { + // PAT expires 2026-01 + "url": "https://api.githubcopilot.com/mcp", + "headers": { "Authorization": "Bearer github_pat_XYZ" } + }, + }, +} diff --git a/test/__setup__/fixtures/mcp-install-fixtures.ts b/test/__setup__/fixtures/mcp-install-fixtures.ts index d07a35cae..05638e9b3 100644 --- a/test/__setup__/fixtures/mcp-install-fixtures.ts +++ b/test/__setup__/fixtures/mcp-install-fixtures.ts @@ -1,19 +1,9 @@ +import { fileURLToPath } from 'node:url'; + /** * 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'); +export const cursorMcpJsoncWithCommentsPath = fileURLToPath(new URL('./mcp-install-fixtures.jsonc', import.meta.url)); diff --git a/test/local/commands/mcp/install.test.ts b/test/local/commands/mcp/install.test.ts index e84e943b4..a686bcf20 100644 --- a/test/local/commands/mcp/install.test.ts +++ b/test/local/commands/mcp/install.test.ts @@ -8,7 +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 { cursorMcpJsoncWithCommentsPath } 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'; @@ -122,7 +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 }); - await writeFile(cursorPath, CURSOR_MCP_JSONC_WITH_COMMENTS, 'utf-8'); + await writeFile(cursorPath, await readFile(cursorMcpJsoncWithCommentsPath, 'utf-8'), 'utf-8'); await testRunCommand(MCPInstallCommand, { args_client: 'cursor', flags_token: TEST_TOKEN }); From be7248303c8a6dfca3bfdee449d063a9f117a4fa Mon Sep 17 00:00:00 2001 From: MQ37 Date: Wed, 27 May 2026 13:56:10 +0200 Subject: [PATCH 10/15] fix(mcp): drop redundant unstubAllEnvs that wipes ~/.apify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useAuthSetup() already calls vitest.unstubAllEnvs() in its own afterEach and immediately follows with rm(GLOBAL_CONFIGS_FOLDER(), { recursive, force }). Our local afterEach was running the same unstubAllEnvs first (vitest fires afterEach hooks LIFO), so by the time useAuthSetup's cleanup ran, HOME was already restored to the real value and GLOBAL_CONFIGS_FOLDER() resolved to /.apify — wiping the developer's auth.json, secrets.json, and telemetry on every local run of this test file. CI users started clean so it didn't surface there. Drop the duplicate call; let useAuthSetup own env cleanup. --- test/local/commands/mcp/install.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/local/commands/mcp/install.test.ts b/test/local/commands/mcp/install.test.ts index a686bcf20..0cfccb449 100644 --- a/test/local/commands/mcp/install.test.ts +++ b/test/local/commands/mcp/install.test.ts @@ -40,9 +40,12 @@ beforeEach(async () => { vitest.stubEnv('APIFY_TOKEN', ''); }); +// useAuthSetup() already calls vitest.unstubAllEnvs() in its own afterEach. Calling it again +// here races with useAuthSetup's cleanup — if our afterEach runs first, HOME is restored to +// the real value before useAuthSetup computes GLOBAL_CONFIGS_FOLDER() and rm()s it, wiping +// the developer's real ~/.apify (auth.json, secrets.json, telemetry). afterEach(() => { process.exitCode = undefined; - vitest.unstubAllEnvs(); }); describe('apify mcp install', () => { From f24826b088902687897e3691a2a7a910948f051e Mon Sep 17 00:00:00 2001 From: MQ37 Date: Wed, 27 May 2026 13:56:20 +0200 Subject: [PATCH 11/15] feat(mcp): print APIFY_TOKEN next-step on codex; tighten install copy - install.ts --token help: 'apify auth token' -> 'apify login' (the command that stores the token; 'apify auth token' only prints it, and the auth.ts error message already references 'apify login'). - install.ts interactiveNote: scope --yes to JSON-merge clients (cursor, kiro, antigravity). For CLI-delegated clients (claude-code, vscode, vscode-insiders, codex), --yes is silently a no-op since overwrite semantics are owned by the child CLI. - clients.ts codexHandler: codex configures bearer_token_env_var but never embeds the token, so users hit 401 if APIFY_TOKEN isn't in the environment when they launch codex. The not-found branch already mentioned this; the success branch did not. Add a Next: line on success too, and reword both to say 'in the same shell' rather than 'in your shell rc' (a one-off export is fine). - printResult: new optional nextSteps field so other handlers can surface post-install instructions in the same block. --- src/commands/mcp/install.ts | 4 ++-- src/lib/mcp/clients.ts | 7 ++++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/commands/mcp/install.ts b/src/commands/mcp/install.ts index 9a1a50363..7ac547690 100644 --- a/src/commands/mcp/install.ts +++ b/src/commands/mcp/install.ts @@ -19,7 +19,7 @@ export class MCPInstallCommand extends ApifyCommand { static override interactive = true; static override interactiveNote = - 'Prompts before overwriting an existing config entry. Pass --yes to overwrite without prompting.'; + 'Prompts before overwriting an existing JSON config entry (cursor, kiro, antigravity). Pass --yes to overwrite without prompting. For clients delegated to their own CLI (claude-code, vscode, vscode-insiders, codex), overwrite behavior is controlled by that CLI.'; static override examples = [ { @@ -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 auth token'.`, + description: `Apify API token to embed in the config. Defaults to the token from 'apify login'.`, }), url: Flags.string({ description: 'Apify MCP server URL.', diff --git a/src/lib/mcp/clients.ts b/src/lib/mcp/clients.ts index 2fd6dc09d..c4955e934 100644 --- a/src/lib/mcp/clients.ts +++ b/src/lib/mcp/clients.ts @@ -43,6 +43,7 @@ interface InstallResult { serverUrl: string; authDescription: string; configPath?: string; + nextSteps?: string; } function printResult(result: InstallResult): void { @@ -56,6 +57,9 @@ function printResult(result: InstallResult): void { if (result.configPath) { lines.push(` ${chalk.yellow('Config:')} ${tildify(result.configPath)}`); } + if (result.nextSteps) { + lines.push('', result.nextSteps); + } simpleLog({ message: lines.join('\n') }); } @@ -198,7 +202,7 @@ const codexHandler: ClientHandler = async ({ url }) => { '\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=`, + 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, before launching codex, export your Apify token in the same shell:\n\n export APIFY_TOKEN=`, }); process.exitCode = CommandExitCodes.NotFound; return; @@ -221,6 +225,7 @@ const codexHandler: ClientHandler = async ({ url }) => { serverUrl: url, authDescription: `Bearer token from APIFY_TOKEN environment variable`, configPath: tomlPath, + nextSteps: ` ${chalk.yellow('Next:')} Codex reads APIFY_TOKEN from the environment. Before launching codex, run:\n\n export APIFY_TOKEN=`, }); }; From d4febae8d0c6fc960283fc273393b9b3437af7bc Mon Sep 17 00:00:00 2001 From: MQ37 Date: Wed, 27 May 2026 13:56:30 +0200 Subject: [PATCH 12/15] refactor(mcp): replace jsonc-parser with existing jju dep jju was already a dependency (used by actors/pull.ts for the same class of JSONC round-trip). Verified that jju.update preserves top-of-file comments, inline comments, trailing commas, indentation, and unrelated sibling keys for every fixture the test suite asserts on. Greenfield writes (missing/empty file) go through jju.stringify instead of jju.update because update from a '{}' seed produces a collapsed inline shape; stringify gives clean 2-space indented JSON. Net result: one fewer direct dep, no behavior change at the test-fixture level (all 13 install.test.ts cases pass). --- package.json | 1 - pnpm-lock.yaml | 8 -------- src/lib/mcp/file-config.ts | 39 +++++++++++++++++++++++--------------- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/package.json b/package.json index 28798370c..899b8b644 100644 --- a/package.json +++ b/package.json @@ -105,7 +105,6 @@ "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 8adc5a975..8d85091f8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -131,9 +131,6 @@ 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 @@ -6113,9 +6110,6 @@ 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==} @@ -15957,8 +15951,6 @@ 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/file-config.ts b/src/lib/mcp/file-config.ts index 8dcf7c5fb..659104038 100644 --- a/src/lib/mcp/file-config.ts +++ b/src/lib/mcp/file-config.ts @@ -2,7 +2,7 @@ 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 jju from 'jju'; import { useYesNoConfirm } from '../hooks/user-confirmations/useYesNoConfirm.js'; import { simpleLog } from '../outputs.js'; @@ -25,10 +25,12 @@ async function confirmOverwrite({ filePath, entryKey, yes }: ConfirmOverwriteOpt } /** - * 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). + * Insert or replace one entry in a JSONC config file. + * + * Uses `jju.update`, which re-renders the original text from its token stream — comments, + * indentation, trailing commas, and unrelated keys all survive. Greenfield writes (missing + * or empty file) go through `jju.stringify` so the output is properly indented instead of + * the collapsed shape `jju.update` produces from a `{}` seed. * * Returns true if the file was written, false if the user declined the overwrite. */ @@ -46,28 +48,35 @@ export async function mergeServerEntry({ yes: boolean; }): Promise { const text = existsSync(filePath) ? await readFile(filePath, 'utf-8') : ''; - const root = text.trim() ? parseTree(text) : undefined; + const trimmed = text.trim(); + + let newText: string; + if (!trimmed) { + const document = { [topLevelKey]: { [entryKey]: serverEntry } }; + newText = `${jju.stringify(document, { mode: 'json', indent: 2 })}\n`; + } else { + const document = jju.parse(text) as Record; - if (root) { - const topLevelNode = findNodeAtLocation(root, [topLevelKey]); - if (topLevelNode && topLevelNode.type !== 'object') { + const existingTopLevel = document[topLevelKey]; + if (existingTopLevel != null && (typeof existingTopLevel !== 'object' || Array.isArray(existingTopLevel))) { 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 topLevel = (existingTopLevel as Record | undefined) ?? {}; + if (Object.prototype.hasOwnProperty.call(topLevel, entryKey)) { const ok = await confirmOverwrite({ filePath, entryKey, yes }); if (!ok) { simpleLog({ message: 'No changes written.' }); return false; } } - } + topLevel[entryKey] = serverEntry; + document[topLevelKey] = topLevel; - const edits = modify(text, [topLevelKey, entryKey], serverEntry, { - formattingOptions: { tabSize: 2, insertSpaces: true }, - }); - const newText = applyEdits(text, edits); + newText = jju.update(text, document); + } await mkdir(dirname(filePath), { recursive: true }); await writeFile(filePath, newText, 'utf-8'); From f6a807008515619db14a03cc26a0c101c973a8bc Mon Sep 17 00:00:00 2001 From: MQ37 Date: Wed, 27 May 2026 13:59:17 +0200 Subject: [PATCH 13/15] docs(api): regen reference.md for --describe/--search help drift Unrelated upstream drift picked up by 'pnpm run update-docs'. The api command's --describe and --search help strings were updated in #1128 but reference.md wasn't regenerated at the time. Including here only because update-docs is monolithic and refused to re-render just the mcp section. --- docs/reference.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/docs/reference.md b/docs/reference.md index aa5981f8b..c49785827 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -69,10 +69,11 @@ ARGUMENTS FLAGS -d, --body= The request body (JSON string). Use "-" to read from stdin. - --describe= Describe an endpoint: print every HTTP - method on a path, its summary, and path parameters. - Accepts a path like "actor-runs/{runId}" or - "/v2/actor-runs/{runId}". + --describe= Print a reference for an endpoint + path: its HTTP methods, summary, and path parameters. + Leading slashes and a version prefix in the path are + optional. For example, "actor-runs/{runId}" and + "/v2/actor-runs/{runId}" are both accepted. -H, --header= Additional HTTP header(s). Pass a single "key:value" string, or a JSON object like '{"X-Foo": "bar", "X-Baz": "qux"}' to send multiple @@ -85,9 +86,11 @@ FLAGS -p, --params= Query parameters as a JSON object, e.g. '{"limit": 1, "desc": true}'. - -s, --search= Filter --list-endpoints by a - space-separated query. Each token must appear - (case-insensitive) in method, path, or summary. + -s, --search= Filter results returned by + --list-endpoints. The query is case-insensitive and split + into tokens by spaces. For an endpoint to be returned, + every token must appear in that endpoint's method, path, + or summary. ``` ##### `apify telemetry` From 772e042a49eb60612089cb129ef2829fc8f80817 Mon Sep 17 00:00:00 2001 From: MQ37 Date: Fri, 29 May 2026 13:09:09 +0200 Subject: [PATCH 14/15] refactor(mcp): dedupe --add-mcp server JSON into one builder --- src/lib/mcp/clients.ts | 29 +++++++---------------------- 1 file changed, 7 insertions(+), 22 deletions(-) diff --git a/src/lib/mcp/clients.ts b/src/lib/mcp/clients.ts index c4955e934..b87ad992f 100644 --- a/src/lib/mcp/clients.ts +++ b/src/lib/mcp/clients.ts @@ -141,39 +141,24 @@ async function addMcpViaCli({ url: string; token: string; }): Promise { + // VS Code's '--add-mcp' takes one JSON server descriptor; only the bearer value differs between the real, masked, and placeholder forms. + const buildServerJson = (authValue: string) => + JSON.stringify({ name: SERVER_KEY, type: 'http', url, headers: { Authorization: authValue } }); + 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}'`, + message: `The '${binary}' CLI was not found on PATH. Install ${clientLabel} and re-run, or add the server manually:\n\n ${binary} --add-mcp '${buildServerJson('Bearer ')}'`, }); process.exitCode = CommandExitCodes.NotFound; return; } - 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)}` }, - }); - - run({ message: `${binary} --add-mcp '${maskedJson}'` }); + run({ message: `${binary} --add-mcp '${buildServerJson(`Bearer ${maskToken(token)}`)}'` }); try { - await execa(binary, ['--add-mcp', serverJson], { stdio: ['ignore', 'ignore', 'inherit'] }); + await execa(binary, ['--add-mcp', buildServerJson(`Bearer ${token}`)], { stdio: ['ignore', 'ignore', 'inherit'] }); } catch (err) { error({ message: `Failed to add the MCP server via ${clientLabel}: ${describeExecaError(err, binary)}` }); process.exitCode = CommandExitCodes.RunFailed; From 80ea43a0288f2d33136ebc8a910ccb6e2f40f346 Mon Sep 17 00:00:00 2001 From: MQ37 Date: Fri, 29 May 2026 13:35:37 +0200 Subject: [PATCH 15/15] refactor(mcp): collapse per-client handlers into two strategies Seven hand-rolled install handlers shared only two shapes: merge a JSON config file, or shell out to the client's own CLI. Replace them with a fileClient factory and a runCliInstall helper, derive the supported-client list from the handler map (one source of truth), fold exec-helpers in, and inline the single-use token resolver and test fixture path. No behavior change: every user-facing string and exit code is preserved; docs/reference.md is unchanged and the install test suite stays green. Net -44 lines, -3 files. --- src/commands/mcp/install.ts | 20 +- src/lib/mcp/auth.ts | 23 -- src/lib/mcp/clients.ts | 323 ++++++++---------- src/lib/mcp/exec-helpers.ts | 13 - .../fixtures/mcp-install-fixtures.ts | 9 - test/local/commands/mcp/install.test.ts | 8 +- 6 files changed, 176 insertions(+), 220 deletions(-) delete mode 100644 src/lib/mcp/auth.ts delete mode 100644 src/lib/mcp/exec-helpers.ts delete mode 100644 test/__setup__/fixtures/mcp-install-fixtures.ts diff --git a/src/commands/mcp/install.ts b/src/commands/mcp/install.ts index 7ac547690..9c5a64fc6 100644 --- a/src/commands/mcp/install.ts +++ b/src/commands/mcp/install.ts @@ -4,10 +4,28 @@ 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'; +import { getLocalUserInfo } from '../../lib/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. + */ +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; +} export class MCPInstallCommand extends ApifyCommand { static override name = 'install' as const; diff --git a/src/lib/mcp/auth.ts b/src/lib/mcp/auth.ts deleted file mode 100644 index f3e0c95f1..000000000 --- a/src/lib/mcp/auth.ts +++ /dev/null @@ -1,23 +0,0 @@ -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 index b87ad992f..911538dbe 100644 --- a/src/lib/mcp/clients.ts +++ b/src/lib/mcp/clients.ts @@ -2,31 +2,17 @@ import { join } from 'node:path'; import process from 'node:process'; import chalk from 'chalk'; -import { execa } from 'execa'; +import { execa, type ExecaError } 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 { mergeServerEntry } from './file-config.js'; import { maskToken } from './url.js'; -export const SUPPORTED_CLIENTS = [ - 'claude-code', - 'cursor', - 'vscode', - 'vscode-insiders', - '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); -} +const SERVER_KEY = 'apify'; +const bearer = (token: string) => ({ Authorization: `Bearer ${token}` }); export interface InstallContext { url: string; @@ -36,8 +22,6 @@ export interface InstallContext { type ClientHandler = (ctx: InstallContext) => Promise; -const SERVER_KEY = 'apify'; - interface InstallResult { clientLabel: string; serverUrl: string; @@ -64,201 +48,194 @@ function printResult(result: InstallResult): void { simpleLog({ message: lines.join('\n') }); } -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)`, - }); -}; - -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', entryKey: SERVER_KEY, serverEntry, yes }); - if (!wrote) return; +function isExecaError(err: unknown): err is ExecaError { + return typeof err === 'object' && err !== null && 'shortMessage' in err && 'command' in err; +} - printResult({ - clientLabel: 'Cursor', - serverUrl: url, - authDescription: `Bearer ${maskToken(token)}`, - configPath: filePath, - }); -}; +/** Build a user-facing description of an execa failure, falling back to signal / shortMessage when exitCode is null. */ +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; +} /** - * 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. + * Shell out to a client's own CLI to register the server. Child stdout is discarded — + * these CLIs echo the bearer token on success; stderr stays inherited for live errors. + * Returns false (and sets process.exitCode) when the binary is missing or the run fails. */ -async function addMcpViaCli({ +async function runCliInstall({ binary, clientLabel, - url, - token, + args, + maskedCommand, + missingMessage, }: { binary: string; clientLabel: string; - url: string; - token: string; -}): Promise { - // VS Code's '--add-mcp' takes one JSON server descriptor; only the bearer value differs between the real, masked, and placeholder forms. - const buildServerJson = (authValue: string) => - JSON.stringify({ name: SERVER_KEY, type: 'http', url, headers: { Authorization: authValue } }); - - const bin = await which(binary, { nothrow: true }); - - if (!bin) { - 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 '${buildServerJson('Bearer ')}'`, - }); + args: string[]; + maskedCommand: string; + missingMessage: string; +}): Promise { + if (!(await which(binary, { nothrow: true }))) { + error({ message: missingMessage }); process.exitCode = CommandExitCodes.NotFound; - return; + return false; } - run({ message: `${binary} --add-mcp '${buildServerJson(`Bearer ${maskToken(token)}`)}'` }); - + run({ message: maskedCommand }); try { - await execa(binary, ['--add-mcp', buildServerJson(`Bearer ${token}`)], { stdio: ['ignore', 'ignore', 'inherit'] }); + await execa(binary, args, { stdio: ['ignore', 'ignore', 'inherit'] }); } catch (err) { error({ message: `Failed to add the MCP server via ${clientLabel}: ${describeExecaError(err, binary)}` }); process.exitCode = CommandExitCodes.RunFailed; - return; + return false; } - - printResult({ - clientLabel, - serverUrl: url, - authDescription: `Bearer ${maskToken(token)} (stored by ${clientLabel})`, - }); + return true; } -const vscodeHandler: ClientHandler = async ({ url, token }) => - addMcpViaCli({ binary: 'code', clientLabel: 'VS Code', url, token }); - -const vscodeInsidersHandler: ClientHandler = async ({ url, token }) => - addMcpViaCli({ binary: 'code-insiders', clientLabel: 'VS Code Insiders', url, token }); - -const codexHandler: ClientHandler = async ({ url }) => { - const codexBin = await which('codex', { nothrow: true }); - const tomlPath = join(userHomeDir(), '.codex', 'config.toml'); +/** Merge an 'apify' entry into a client's JSONC config file at ~/. */ +function fileClient({ + label, + segments, + entry, +}: { + label: string; + segments: string[]; + entry: (url: string, token: string) => Record; +}): ClientHandler { + return async ({ url, token, yes }) => { + const filePath = join(userHomeDir(), ...segments); + const wrote = await mergeServerEntry({ + filePath, + topLevelKey: 'mcpServers', + entryKey: SERVER_KEY, + serverEntry: entry(url, token), + yes, + }); + if (!wrote) return; - 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, before launching codex, export your Apify token in the same shell:\n\n export APIFY_TOKEN=`, + printResult({ + clientLabel: label, + serverUrl: url, + authDescription: `Bearer ${maskToken(token)}`, + configPath: filePath, }); - process.exitCode = CommandExitCodes.NotFound; - return; - } + }; +} - const args = ['mcp', 'add', SERVER_KEY, '--url', url, '--bearer-token-env-var', 'APIFY_TOKEN']; - run({ message: `codex ${args.join(' ')}` }); +const claudeCodeHandler: ClientHandler = async ({ url, token }) => { + // Per Anthropic docs, all options must come before the server name. placeholder avoids printing the real token. + const command = (auth: string) => + `claude mcp add --transport http --scope user ${SERVER_KEY} "${url}" --header "Authorization: Bearer ${auth}"`; - 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; - } + const ok = await runCliInstall({ + binary: 'claude', + clientLabel: 'Claude Code', + args: [ + 'mcp', + 'add', + '--transport', + 'http', + '--scope', + 'user', + SERVER_KEY, + url, + '--header', + `Authorization: Bearer ${token}`, + ], + maskedCommand: command(maskToken(token)), + missingMessage: `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 ${command('')}`, + }); + if (!ok) return; printResult({ - clientLabel: 'Codex CLI', + clientLabel: 'Claude Code', serverUrl: url, - authDescription: `Bearer token from APIFY_TOKEN environment variable`, - configPath: tomlPath, - nextSteps: ` ${chalk.yellow('Next:')} Codex reads APIFY_TOKEN from the environment. Before launching codex, run:\n\n export APIFY_TOKEN=`, + authDescription: `Bearer ${maskToken(token)} (stored by Claude Code)`, }); }; -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[], +/** VS Code (stable + insiders): register via ' --add-mcp '. */ +function vscodeHandler(binary: string, clientLabel: string): ClientHandler { + return async ({ url, token }) => { + const serverJson = (auth: string) => + JSON.stringify({ name: SERVER_KEY, type: 'http', url, headers: { Authorization: auth } }); + + const ok = await runCliInstall({ + binary, + clientLabel, + args: ['--add-mcp', serverJson(`Bearer ${token}`)], + maskedCommand: `${binary} --add-mcp '${serverJson(`Bearer ${maskToken(token)}`)}'`, + missingMessage: `The '${binary}' CLI was not found on PATH. Install ${clientLabel} and re-run, or add the server manually:\n\n ${binary} --add-mcp '${serverJson('Bearer ')}'`, + }); + if (!ok) return; + + printResult({ + clientLabel, + serverUrl: url, + authDescription: `Bearer ${maskToken(token)} (stored by ${clientLabel})`, + }); }; +} - const wrote = await mergeServerEntry({ filePath, topLevelKey: 'mcpServers', entryKey: SERVER_KEY, serverEntry, yes }); - if (!wrote) return; +const codexHandler: ClientHandler = async ({ url }) => { + const tomlPath = join(userHomeDir(), '.codex', 'config.toml'); + // codex rejects a literal bearer_token (openai/codex#19275), so auth goes through the APIFY_TOKEN env var. + const args = ['mcp', 'add', SERVER_KEY, '--url', url, '--bearer-token-env-var', 'APIFY_TOKEN']; + const tomlSnippet = [`[mcp_servers.${SERVER_KEY}]`, `url = "${url}"`, `bearer_token_env_var = "APIFY_TOKEN"`].join( + '\n', + ); - printResult({ - clientLabel: 'Kiro', - serverUrl: url, - authDescription: `Bearer ${maskToken(token)}`, - configPath: filePath, + const ok = await runCliInstall({ + binary: 'codex', + clientLabel: 'Codex CLI', + args, + maskedCommand: `codex ${args.join(' ')}`, + missingMessage: `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, before launching codex, export your Apify token in the same shell:\n\n export APIFY_TOKEN=`, }); -}; - -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', entryKey: SERVER_KEY, serverEntry, yes }); - if (!wrote) return; + if (!ok) return; printResult({ - clientLabel: 'Antigravity', + clientLabel: 'Codex CLI', serverUrl: url, - authDescription: `Bearer ${maskToken(token)}`, - configPath: filePath, + authDescription: `Bearer token from APIFY_TOKEN environment variable`, + configPath: tomlPath, + nextSteps: ` ${chalk.yellow('Next:')} Codex reads APIFY_TOKEN from the environment. Before launching codex, run:\n\n export APIFY_TOKEN=`, }); }; -const HANDLERS: Record = { +const HANDLERS = { 'claude-code': claudeCodeHandler, - cursor: cursorHandler, - vscode: vscodeHandler, - 'vscode-insiders': vscodeInsidersHandler, + cursor: fileClient({ + label: 'Cursor', + segments: ['.cursor', 'mcp.json'], + entry: (url, token) => ({ url, headers: bearer(token) }), + }), + vscode: vscodeHandler('code', 'VS Code'), + 'vscode-insiders': vscodeHandler('code-insiders', 'VS Code Insiders'), codex: codexHandler, - kiro: kiroHandler, - antigravity: antigravityHandler, -}; + kiro: fileClient({ + label: 'Kiro', + segments: ['.kiro', 'settings', 'mcp.json'], + entry: (url, token) => ({ url, headers: bearer(token), disabled: false, autoApprove: [] as string[] }), + }), + antigravity: fileClient({ + label: 'Antigravity', + segments: ['.gemini', 'antigravity', 'mcp_config.json'], + // Antigravity uses 'serverUrl' (not 'url'); see https://antigravity.google/docs/mcp. + entry: (url, token) => ({ serverUrl: url, headers: bearer(token) }), + }), +} satisfies Record; + +export type ClientName = keyof typeof HANDLERS; + +export const SUPPORTED_CLIENTS = Object.keys(HANDLERS) as ClientName[]; + +export function isSupportedClient(value: string): value is ClientName { + return value in HANDLERS; +} 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 deleted file mode 100644 index 91ad0921a..000000000 --- a/src/lib/mcp/exec-helpers.ts +++ /dev/null @@ -1,13 +0,0 @@ -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/test/__setup__/fixtures/mcp-install-fixtures.ts b/test/__setup__/fixtures/mcp-install-fixtures.ts deleted file mode 100644 index 05638e9b3..000000000 --- a/test/__setup__/fixtures/mcp-install-fixtures.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { fileURLToPath } from 'node:url'; - -/** - * 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 cursorMcpJsoncWithCommentsPath = fileURLToPath(new URL('./mcp-install-fixtures.jsonc', import.meta.url)); diff --git a/test/local/commands/mcp/install.test.ts b/test/local/commands/mcp/install.test.ts index 0cfccb449..1a5afdb0b 100644 --- a/test/local/commands/mcp/install.test.ts +++ b/test/local/commands/mcp/install.test.ts @@ -1,5 +1,6 @@ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import process from 'node:process'; +import { fileURLToPath } from 'node:url'; // 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. @@ -8,11 +9,16 @@ 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 { cursorMcpJsoncWithCommentsPath } 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'; +// Hand-edited Cursor mcp.json (comments, trailing comma, an unrelated server) — used to +// verify `apify mcp install` round-trips JSONC without clobbering what the user typed. +const cursorMcpJsoncWithCommentsPath = fileURLToPath( + new URL('../../../__setup__/fixtures/mcp-install-fixtures.jsonc', import.meta.url), +); + const TEST_TOKEN = 'apify_api_TEST_xxxxxxxxxxxxxxxxxxxxxx'; const OTHER_TEST_TOKEN = 'apify_api_OTHER_yyyyyyyyyyyyyyyyyyyy';