Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `mcpc login` now falls back to accepting a pasted callback URL when the browser cannot be opened (e.g. headless servers, containers)
- `--async` flag for `tools-call` to opt-in to async task execution (experimental) with a progress spinner showing elapsed time, server status messages, and progress notification messages in human mode
- `--detach` flag for `tools-call` to start an async task and return the task ID immediately without waiting for completion (implies `--async`)
- Press ESC during `--async` task execution to detach on the fly — the task continues in the background and the task ID is printed, same as `--detach`
- New `tasks-list`, `tasks-get`, `tasks-cancel` commands for managing async tasks on the server
- Task capability and `execution.taskSupport` displayed in `tools-get` and server info
- E2E test server now includes a `slow-task` tool that supports async task execution
Expand Down
80 changes: 78 additions & 2 deletions src/cli/commands/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,47 @@ async function shouldUseTask(
return !!details.capabilities?.tasks?.requests?.tools?.call;
}

/**
* Set up ESC key listener for detaching from an async task.
* Returns a promise that resolves when ESC is pressed, and a cleanup function.
* Only activates when enabled=true and stdin is a TTY.
*/
function setupEscListener(
enabled: boolean,
canDetach: () => boolean
): { promise: Promise<'detached'> | null; cleanup: () => void } {
if (!enabled || !process.stdin.isTTY) {
return { promise: null, cleanup: () => {} };
}

const ESC = '\x1b';
let cleaned = false;

let cleanupFn = (): void => {};
const promise = new Promise<'detached'>((resolve) => {
const onData = (key: Buffer): void => {
if (key.toString() === ESC && canDetach()) {
cleanupFn();
resolve('detached');
}
};

process.stdin.setRawMode(true);
process.stdin.resume();
process.stdin.on('data', onData);

cleanupFn = () => {
if (cleaned) return;
cleaned = true;
process.stdin.off('data', onData);
process.stdin.setRawMode(false);
process.stdin.pause();
};
});

return { promise, cleanup: () => cleanupFn() };
}

/**
* Call a tool with arguments
* Arguments can be provided via:
Expand Down Expand Up @@ -225,14 +266,17 @@ export async function callTool(
let timerInterval: ReturnType<typeof setInterval> | null = null;
let lastStatusMessage: string | undefined;
let lastProgressMessage: string | undefined;
let capturedTaskId: string | undefined;

const updateSpinnerText = (): void => {
if (!spinner) return;
const elapsed = formatElapsed(Date.now() - startTime);
const progressSuffix = lastProgressMessage ? ` ${chalk.dim(lastProgressMessage)}` : '';
const statusSuffix =
!lastProgressMessage && lastStatusMessage ? ` ${chalk.dim(lastStatusMessage)}` : '';
spinner.text = `Running tool ${chalk.bold(name)}... (${elapsed})${progressSuffix}${statusSuffix}`;
const escHint =
capturedTaskId && escListener.promise ? ` ${chalk.dim('(ESC to detach)')}` : '';
spinner.text = `Running tool ${chalk.bold(name)}... (${elapsed})${progressSuffix}${statusSuffix}${escHint}`;
};

if (options.outputMode === 'human') {
Expand All @@ -244,6 +288,9 @@ export async function callTool(
}

const onUpdate = (update: TaskUpdate): void => {
if (update.taskId) {
capturedTaskId = update.taskId;
}
if (update.statusMessage) {
lastStatusMessage = update.statusMessage;
}
Expand All @@ -255,13 +302,42 @@ export async function callTool(
}
};

// Set up ESC key listener for detaching (TTY + human mode only, not in interactive shell)
const escListener = setupEscListener(
options.outputMode === 'human' && !process.stdin.isRaw,
() => !!capturedTaskId
);

try {
result = await client.callToolWithTask(name, parsedArgs, onUpdate);
const taskPromise = client.callToolWithTask(name, parsedArgs, onUpdate);

if (escListener.promise) {
const raceResult = await Promise.race([
taskPromise.then((r) => ({ type: 'completed' as const, result: r })),
escListener.promise.then(() => ({ type: 'detached' as const })),
]);

escListener.cleanup();

if (raceResult.type === 'detached') {
if (timerInterval) clearInterval(timerInterval);
if (spinner) {
spinner.info(`Detached. Task ${chalk.bold(capturedTaskId!)} continues in background`);
}
return;
}

result = raceResult.result;
} else {
result = await taskPromise;
}

const elapsed = formatElapsed(Date.now() - startTime);
if (spinner) {
spinner.succeed(`Tool ${chalk.bold(name)} executed successfully (${elapsed})`);
}
} catch (error) {
escListener.cleanup();
const elapsed = formatElapsed(Date.now() - startTime);
if (spinner) {
spinner.fail(`Tool ${chalk.bold(name)} failed (${elapsed})`);
Expand Down
Loading