diff --git a/docs/reference.md b/docs/reference.md index 80d1da98f..aa5981f8b 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 AI client. + + + +##### `apify mcp` + +```sh +DESCRIPTION + 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 + 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, + vscode-insiders, 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/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 a5834240e..ccebbe932 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/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..675eecb8f 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 AI client. + + + + + 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..60ec422e7 --- /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 AI client: Claude Code, Cursor, VS Code, Codex CLI, Kiro, or Antigravity.`; + + 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..9a1a50363 --- /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 auth token'.`, + }), + 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..2fd6dc09d --- /dev/null +++ b/src/lib/mcp/clients.ts @@ -0,0 +1,275 @@ +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 { 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); +} + +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; +} + +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)}`); + } + + 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; + + printResult({ + clientLabel: 'Cursor', + serverUrl: url, + authDescription: `Bearer ${maskToken(token)}`, + configPath: filePath, + }); +}; + +/** + * 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, + url, + token, +}: { + binary: string; + clientLabel: 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 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}'` }); + + 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, + serverUrl: url, + authDescription: `Bearer ${maskToken(token)} (stored by ${clientLabel})`, + }); +} + +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'); + + 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, + }); +}; + +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', entryKey: SERVER_KEY, serverEntry, yes }); + if (!wrote) return; + + printResult({ + clientLabel: 'Kiro', + serverUrl: url, + authDescription: `Bearer ${maskToken(token)}`, + configPath: filePath, + }); +}; + +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; + + printResult({ + clientLabel: 'Antigravity', + serverUrl: url, + authDescription: `Bearer ${maskToken(token)}`, + configPath: filePath, + }); +}; + +const HANDLERS: Record = { + 'claude-code': claudeCodeHandler, + cursor: cursorHandler, + vscode: vscodeHandler, + 'vscode-insiders': vscodeInsidersHandler, + 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..8dcf7c5fb --- /dev/null +++ b/src/lib/mcp/file-config.ts @@ -0,0 +1,75 @@ +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'; + +interface ConfirmOverwriteOptions { + filePath: string; + entryKey: string; + yes: boolean; +} + +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.`, + }); +} + +/** + * 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/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/__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 new file mode 100644 index 000000000..e84e943b4 --- /dev/null +++ b/test/local/commands/mcp/install.test.ts @@ -0,0 +1,202 @@ +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 +// 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 { 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'; + +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', 'vscode-insiders', '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: 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 }); + await writeFile(cursorPath, CURSOR_MCP_JSONC_WITH_COMMENTS, '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); + + 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'); + }); + + // 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 }); + + 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); + }); +});