Skip to content
8 changes: 5 additions & 3 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -1984,12 +1984,14 @@ ARGUMENTS

FLAGS
-t, --token=<value> Apify API token to embed in the config.
Defaults to APIFY_TOKEN, then the token from 'apify login'.
Defaults to APIFY_TOKEN, then the token from 'apify login'. A
token from an OAuth 'apify login' expires after about an
hour, so pass a long-lived one from Apify Console.
--tools=<value> Comma-separated tool IDs or Actor full names
to expose. Forwarded as a '?tools=' query parameter.
--url=<value> Apify MCP server URL.
-y, --yes Overwrite an existing 'apify' entry
without prompting.
-y, --yes Overwrite an existing 'apify' entry, and
accept a token that's about to expire, without prompting.
```
<!-- mcp-commands-end -->
<!-- prettier-ignore-end -->
2 changes: 1 addition & 1 deletion src/commands/actors/rm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export class ActorsRmCommand extends ApifyCommand<typeof ActorsRmCommand> {

const confirmedDelete = await useYesNoConfirm({
message: `Are you sure you want to delete this Actor?`,
providedConfirmFromStdin: yes || undefined,
preAnswered: yes || undefined,
});

if (!confirmedDelete) {
Expand Down
2 changes: 1 addition & 1 deletion src/commands/builds/remove-tag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export class BuildsRemoveTagCommand extends ApifyCommand<typeof BuildsRemoveTagC
// Confirm removal
const confirmed = await useYesNoConfirm({
message: `Are you sure you want to remove tag "${chalk.yellow(tag)}" from build ${chalk.gray(build.buildNumber)}?`,
providedConfirmFromStdin: yes || undefined,
preAnswered: yes || undefined,
});

if (!confirmed) {
Expand Down
4 changes: 2 additions & 2 deletions src/commands/builds/rm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,12 @@ export class BuildsRmCommand extends ApifyCommand<typeof BuildsRmCommand> {
message: `Are you sure you want to delete this Actor Build? If so, please type in "${confirmationPrompt}":`,
expectedValue: confirmationPrompt,
failureMessage: 'Your provided value does not match the build tag.',
providedConfirmFromStdin: yes ? confirmationPrompt : undefined,
preAnswered: yes ? confirmationPrompt : undefined,
});
} else {
confirmed = await useYesNoConfirm({
message: `Are you sure you want to delete this Actor Build?`,
providedConfirmFromStdin: yes || undefined,
preAnswered: yes || undefined,
});
}

Expand Down
2 changes: 1 addition & 1 deletion src/commands/cli-management/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ export class InstallCommand extends ApifyCommand<typeof InstallCommand> {
allowedToAutomaticallyDo = await useYesNoConfirm({
message: confirmMessage,
// For now, no stdin -> always false
providedConfirmFromStdin: false,
defaultWhenNonInteractive: false,
});
}

Expand Down
2 changes: 1 addition & 1 deletion src/commands/datasets/rm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export class DatasetsRmCommand extends ApifyCommand<typeof DatasetsRmCommand> {

const confirmed = await useYesNoConfirm({
message: `Are you sure you want to delete this Dataset?`,
providedConfirmFromStdin: yes || undefined,
preAnswered: yes || undefined,
});

if (!confirmed) {
Expand Down
2 changes: 1 addition & 1 deletion src/commands/key-value-stores/delete-value.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export class KeyValueStoresDeleteValueCommand extends ApifyCommand<typeof KeyVal

const confirm = await useYesNoConfirm({
message: `Are you sure you want to delete this record?`,
providedConfirmFromStdin: yes || undefined,
preAnswered: yes || undefined,
});

if (!confirm) {
Expand Down
2 changes: 1 addition & 1 deletion src/commands/key-value-stores/rm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export class KeyValueStoresRmCommand extends ApifyCommand<typeof KeyValueStoresR

const confirmed = await useYesNoConfirm({
message: `Are you sure you want to delete this Key-value store?`,
providedConfirmFromStdin: yes || undefined,
preAnswered: yes || undefined,
});

if (!confirmed) {
Expand Down
104 changes: 96 additions & 8 deletions src/commands/mcp/install.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,95 @@
import process from 'node:process';

import { resolveAuth } from '../../lib/auth.js';
import { isCI } from 'ci-info';

import { lookUpProfile } from '../../lib/auth-file.js';
import { type ResolvedAuth, resolveAuth } from '../../lib/auth.js';
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 { getConsoleIntegrationsUrl } from '../../lib/console-url.js';
import { CommandExitCodes } from '../../lib/consts.js';
import { useYesNoConfirm } from '../../lib/hooks/user-confirmations/useYesNoConfirm.js';
import { useStdin } from '../../lib/hooks/useStdin.js';
import { clientNeedsToken, 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 { error, simpleLog, warning } from '../../lib/outputs.js';

const MINUTE_MS = 60_000;
const HOUR_MS = 60 * MINUTE_MS;
const DAY_MS = 24 * HOUR_MS;

/**
* Above this, the embedded token outlives any one working session and the warning would be noise.
* The authorization server picks the lifetime, so a longer-lived token is the server's call to make.
*/
const EXPIRY_WARNING_THRESHOLD_MS = DAY_MS;

function plural(count: number, unit: string): string {
return `${count} ${count === 1 ? unit : `${unit}s`}`;
}

/** How long the token has left, as a clause that follows the token it describes. */
function describeExpiry(remainingMs: number): string {
// A refresh that could not reach the token endpoint hands back the stored token, which by then
// may have minutes or seconds left — never round that down to a reassuring "0 minutes".
if (remainingMs <= 0) return 'has already expired';
if (remainingMs < MINUTE_MS) return 'expires in under a minute';
if (remainingMs < HOUR_MS) return `expires in about ${plural(Math.round(remainingMs / MINUTE_MS), 'minute')}`;
if (remainingMs < DAY_MS) return `expires in about ${plural(Math.round(remainingMs / HOUR_MS), 'hour')}`;

return `expires in about ${plural(Math.round(remainingMs / DAY_MS), 'day')}`;
}

/**
* The token this command writes into a client's config is a copy: nothing refreshes it there. An
* OAuth login's access token expires after about an hour, so the install silently stops working
* unless the user passes a long-lived API token instead.
*
* Returns false when the user declines, in which case nothing should be written.
*/
async function confirmExpiringToken(auth: ResolvedAuth, yes: boolean): Promise<boolean> {
if (!auth.profile) return true;

const { profile } = lookUpProfile(auth.profile.id);
if (profile?.authMethod !== 'oauth2' || !profile.expiresAt) return true;

// An unparseable expiry is not evidence of a short-lived token, and `getAccessToken` ignores it too.
const remainingMs = Date.parse(profile.expiresAt) - Date.now();
if (!Number.isFinite(remainingMs) || remainingMs > EXPIRY_WARNING_THRESHOLD_MS) return true;

const expiry = describeExpiry(remainingMs);
// The Console account the token has to be created under: a bare URL would act on whatever
// account the browser was last on, which for an organization login is the wrong one.
const tokensUrl = getConsoleIntegrationsUrl({
id: auth.profile.id,
organizationOwnerUserId: profile.organizationOwnerUserId,
});

const explanation =
`You're logged in through your Apify account, and that login's API token ${expiry}. ` +
`The MCP client reads the token from its config file and can't refresh it, so the server stops working once the token expires.\n` +
`Pass --token with an API token from Apify Console (${tokensUrl}) to configure a client that keeps working.`;

if (yes) {
warning({ message: explanation });
return true;
}

// `useStdin` reports `hasData` for any stdin that is not a terminal, /dev/null and a closed
// descriptor included, so the prompt hook would ask a question nobody can answer and then wait
// on input that never comes. A Docker build or a cron job has to fail with the hint instead.
const { isTTY } = await useStdin();
if (isCI || !isTTY) {
process.exitCode = CommandExitCodes.InvalidInput;
// The whole explanation, as one message: there is no prompt to follow it up with.
throw new Error(`${explanation}\nRe-run with --yes to write the expiring token anyway.`);
}

warning({ message: explanation });

return useYesNoConfirm({ message: 'Write the expiring token to the client config anyway?', default: false });
}

export class MCPInstallCommand extends ApifyCommand<typeof MCPInstallCommand> {
static override name = 'install' as const;
Expand All @@ -19,7 +101,7 @@ export class MCPInstallCommand extends ApifyCommand<typeof MCPInstallCommand> {
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.';
'Prompts before overwriting an existing JSON config entry (cursor, kiro, antigravity), and before embedding a token that expires within a day. Pass --yes to confirm 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 = [
{
Expand Down Expand Up @@ -50,10 +132,10 @@ export class MCPInstallCommand extends ApifyCommand<typeof MCPInstallCommand> {
};

static override flags = {
...YesFlag(`Overwrite an existing 'apify' entry without prompting.`),
...YesFlag(`Overwrite an existing 'apify' entry, and accept a token that's about to expire, without prompting.`),
token: Flags.string({
char: 't',
description: `Apify API token to embed in the config. Defaults to APIFY_TOKEN, then the token from 'apify login'.`,
description: `Apify API token to embed in the config. Defaults to APIFY_TOKEN, then the token from 'apify login'. A token from an OAuth 'apify login' expires after about an hour, so pass a long-lived one from Apify Console.`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
description: `Apify API token to embed in the config. Defaults to APIFY_TOKEN, then the token from 'apify login'. A token from an OAuth 'apify login' expires after about an hour, so pass a long-lived one from Apify Console.`,
description: `Apify API token to embed in the config. Defaults to APIFY_TOKEN, then the token from 'apify login'. A token from an OAuth 'apify login' expires after about an hour, so pass a personal one from Apify Console.`,

Long-lived sounds wonky

}),
url: Flags.string({
description: 'Apify MCP server URL.',
Expand All @@ -76,16 +158,22 @@ export class MCPInstallCommand extends ApifyCommand<typeof MCPInstallCommand> {
return;
}

let token = '';
if (clientNeedsToken(client)) {
const auth = tokenFlag ? { token: tokenFlag } : await resolveAuth();
let token = tokenFlag ?? '';
if (clientNeedsToken(client) && !tokenFlag) {
const auth = await resolveAuth();
if (!auth) {
error({
message: `You are not logged in to Apify. Run 'apify login' first, or pass --token <api-token>.`,
});
process.exitCode = CommandExitCodes.MissingAuth;
return;
}

if (!(await confirmExpiringToken(auth, yes))) {
simpleLog({ message: 'No changes written.' });
return;
}

token = auth.token;
}

Expand Down
2 changes: 1 addition & 1 deletion src/commands/runs/rm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export class RunsRmCommand extends ApifyCommand<typeof RunsRmCommand> {

const confirmedDelete = await useYesNoConfirm({
message: `Are you sure you want to delete this Actor Run?`,
providedConfirmFromStdin: yes || undefined,
preAnswered: yes || undefined,
});

if (!confirmedDelete) {
Expand Down
22 changes: 16 additions & 6 deletions src/lib/hooks/user-confirmations/_stdinCheckWrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,15 @@ export const promptContext = { output: process.stderr };

export interface StdinCheckWrapperInput<ReturnedType> extends StdinCheckWrapperOptions {
/**
* When set, this value will be used in environments where stdin is not available.
* The answer the user already gave on the command line, typically through `--yes`. Skips the
* prompt everywhere, a TTY included: a flag that promises "without prompting" has to hold there too.
*/
providedConfirmFromStdin?: ReturnedType;
preAnswered?: ReturnedType;
/**
* What to assume when there is nobody to ask. Unlike {@link preAnswered} this is not the user's
* answer, so it never suppresses a prompt someone could actually have answered.
*/
defaultWhenNonInteractive?: ReturnedType;
}

type AllButFirst<T extends any[]> = T extends [any, ...infer Rest] ? Rest : never;
Expand Down Expand Up @@ -47,18 +53,22 @@ export function stdinCheckWrapper<Fn extends (...args: any[]) => any>(
}: StdinCheckWrapperOptions = {},
): (...args: NewFunctionArgs<Fn>) => Promise<Awaited<ReturnType<Fn>>> {
return async (input, ...rest) => {
const { isTTY } = await useStdin();

const casted = input as StdinCheckWrapperInput<Awaited<ReturnType<Fn>>>;

if (typeof casted.preAnswered !== 'undefined') {
return casted.preAnswered;
}

const { isTTY } = await useStdin();

// Prompts need a terminal to read the answer from. Piped stdin is command input, not an
// answer source.
if (isCI || !isTTY) {
if (typeof casted.providedConfirmFromStdin === 'undefined') {
if (typeof casted.defaultWhenNonInteractive === 'undefined') {
throw new Error(casted.errorMessageForStdin ?? errorMessageForStdin);
}

return casted.providedConfirmFromStdin;
return casted.defaultWhenNonInteractive;
}

const res = await fn(input, ...rest);
Expand Down
2 changes: 1 addition & 1 deletion src/lib/mcp/file-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ async function confirmOverwrite({ filePath, entryKey, yes }: ConfirmOverwriteOpt
return useYesNoConfirm({
message: `A server entry named '${entryKey}' already exists in ${tildify(filePath)}. Overwrite it?`,
default: false,
providedConfirmFromStdin: yes || undefined,
preAnswered: yes || undefined,
errorMessageForStdin: `An '${entryKey}' entry already exists in ${tildify(filePath)}. Re-run with --yes to overwrite.`,
});
}
Expand Down
76 changes: 76 additions & 0 deletions test/local/commands/mcp/install-non-tty.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { existsSync } from 'node:fs';
import { mkdir, writeFile } from 'node:fs/promises';
import process from 'node:process';

// Deliberately NOT mocking `isCI`: a Docker build or a cron job is non-interactive without being
// CI, and `useStdin` reports `hasData` for such a stdin, so the expiry prompt used to hang there.
vitest.mock('ci-info', async (importOriginal) => {
const original = await importOriginal<typeof import('ci-info')>();
return { ...original, isCI: false };
});

import { MCPInstallCommand } from '../../../../src/commands/mcp/install.js';
import { testRunCommand } from '../../../../src/lib/command-framework/apify-command.js';
import { AUTH_FILE_PATH, CommandExitCodes, GLOBAL_CONFIGS_FOLDER } from '../../../../src/lib/consts.js';
import { setSecret } from '../../../../src/lib/credentials.js';
import { TEST_USER_ID, v2AuthFile } from '../../../__setup__/auth-file.js';
import { useAuthSetup } from '../../../__setup__/hooks/useAuthSetup.js';
import { useConsoleSpy } from '../../../__setup__/hooks/useConsoleSpy.js';
import { useTempPath } from '../../../__setup__/hooks/useTempPath.js';

const STORED_TOKEN = 'apify_api_STORED_zzzzzzzzzzzzzzzzzzzz';

const { tmpPath, joinPath, beforeAllCalls, afterAllCalls } = useTempPath('mcp-install-non-tty');

useAuthSetup();

const { logMessages } = useConsoleSpy();

beforeAll(beforeAllCalls);
afterAll(afterAllCalls);

beforeEach(async () => {
vitest.stubEnv('HOME', tmpPath);
vitest.stubEnv('APIFY_TOKEN', '');

await mkdir(GLOBAL_CONFIGS_FOLDER(), { recursive: true });
await writeFile(
AUTH_FILE_PATH(),
JSON.stringify(
v2AuthFile({
authMethod: 'oauth2',
expiresAt: new Date(Date.now() + 50 * 60_000).toISOString(),
hasRefreshToken: true,
oauth: {
issuer: 'https://issuer.test',
clientId: 'https://client.test/oauth-client.json',
tokenEndpoint: 'https://issuer.test/oauth/apps/token',
refreshTokenExpiresAt: new Date(Date.now() + 60 * 24 * 60 * 60_000).toISOString(),
},
}),
),
'utf-8',
);
await setSecret(TEST_USER_ID, 'token', STORED_TOKEN);
});

afterEach(() => {
process.exitCode = undefined;
});

describe('apify mcp install, non-interactive but not CI', () => {
it('fails with the --token hint instead of waiting on a prompt nobody can answer', async () => {
await testRunCommand(MCPInstallCommand, { args_client: 'cursor' });

expect(process.exitCode).toBe(CommandExitCodes.InvalidInput);
expect(logMessages.error.join('\n')).toMatch(/Pass --token with an API token/);
expect(existsSync(joinPath('.cursor', 'mcp.json'))).toBe(false);
});

it('--yes writes the config without prompting', async () => {
await testRunCommand(MCPInstallCommand, { args_client: 'cursor', flags_yes: true });

expect(process.exitCode).toBeUndefined();
expect(existsSync(joinPath('.cursor', 'mcp.json'))).toBe(true);
});
});
Loading
Loading