diff --git a/docs/reference.md b/docs/reference.md index 80d1da98f..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` @@ -1573,3 +1576,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/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..9c5a64fc6 --- /dev/null +++ b/src/commands/mcp/install.ts @@ -0,0 +1,102 @@ +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 { 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; + + 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 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 = [ + { + 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/clients.ts b/src/lib/mcp/clients.ts new file mode 100644 index 000000000..911538dbe --- /dev/null +++ b/src/lib/mcp/clients.ts @@ -0,0 +1,242 @@ +import { join } from 'node:path'; +import process from 'node:process'; + +import chalk from 'chalk'; +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 { mergeServerEntry } from './file-config.js'; +import { maskToken } from './url.js'; + +const SERVER_KEY = 'apify'; +const bearer = (token: string) => ({ Authorization: `Bearer ${token}` }); + +export interface InstallContext { + url: string; + token: string; + yes: boolean; +} + +type ClientHandler = (ctx: InstallContext) => Promise; + +interface InstallResult { + clientLabel: string; + serverUrl: string; + authDescription: string; + configPath?: string; + nextSteps?: 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)}`); + } + if (result.nextSteps) { + lines.push('', result.nextSteps); + } + + simpleLog({ message: lines.join('\n') }); +} + +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. */ +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; +} + +/** + * 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 runCliInstall({ + binary, + clientLabel, + args, + maskedCommand, + missingMessage, +}: { + binary: string; + clientLabel: string; + args: string[]; + maskedCommand: string; + missingMessage: string; +}): Promise { + if (!(await which(binary, { nothrow: true }))) { + error({ message: missingMessage }); + process.exitCode = CommandExitCodes.NotFound; + return false; + } + + run({ message: maskedCommand }); + try { + 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 false; + } + return true; +} + +/** 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; + + printResult({ + clientLabel: label, + serverUrl: url, + authDescription: `Bearer ${maskToken(token)}`, + configPath: filePath, + }); + }; +} + +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}"`; + + 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: 'Claude Code', + serverUrl: url, + authDescription: `Bearer ${maskToken(token)} (stored by Claude Code)`, + }); +}; + +/** 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 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', + ); + + 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=`, + }); + if (!ok) return; + + printResult({ + clientLabel: 'Codex CLI', + 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=`, + }); +}; + +const HANDLERS = { + 'claude-code': claudeCodeHandler, + 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: 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/file-config.ts b/src/lib/mcp/file-config.ts new file mode 100644 index 000000000..659104038 --- /dev/null +++ b/src/lib/mcp/file-config.ts @@ -0,0 +1,84 @@ +import { existsSync } from 'node:fs'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +import jju from 'jju'; + +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.`, + }); +} + +/** + * 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. + */ +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 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; + + 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.`, + ); + } + + 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; + + newText = jju.update(text, document); + } + + 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.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/local/commands/mcp/install.test.ts b/test/local/commands/mcp/install.test.ts new file mode 100644 index 000000000..1a5afdb0b --- /dev/null +++ b/test/local/commands/mcp/install.test.ts @@ -0,0 +1,211 @@ +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. +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'; + +// 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'; + +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', ''); +}); + +// 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; +}); + +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, await readFile(cursorMcpJsoncWithCommentsPath, 'utf-8'), '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); + }); +});