import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { exec } from 'child_process';
import { writeFileSync, chmodSync, unlinkSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { SessionManager } from '../state/session-manager.js';
import { CompletionWatcher, TimeoutError } from '../polling/completion-watcher.js';
import { UserStepsInputSchema } from './schemas.js';
import type { Session, Step } from '../state/types.js';
// Get the path to the CLI script (relative to this module)
function getCliPath(): string {
// The CLI is in the cli/dist folder of the package
const url = new URL('../../cli/dist/index.js', import.meta.url);
return url.pathname;
}
// Open a new terminal window with the user-steps UI
function openTerminalWithUI(sessionId: string): void {
const cliPath = getCliPath();
const command = `node "${cliPath}" --session ${sessionId}`;
console.error(`[user-steps] CLI path: ${cliPath}`);
console.error(`[user-steps] Command: ${command}`);
if (process.platform === 'darwin') {
// Create a temporary shell script that runs the command
// This approach works better than AppleScript in sandboxed environments
const scriptPath = join(tmpdir(), `user-steps-${sessionId}.command`);
const scriptContent = `#!/bin/bash
cd ~
${command}
# Clean up the script file after running
rm -f "${scriptPath}"
`;
try {
// Write the script
writeFileSync(scriptPath, scriptContent);
// Make it executable
chmodSync(scriptPath, '755');
console.error(`[user-steps] Created temp script: ${scriptPath}`);
// Use 'open' to run the .command file - macOS will open it in Terminal.app
exec(`open "${scriptPath}"`, (error, stdout, stderr) => {
if (error) {
console.error('[user-steps] Failed to open terminal:', error.message);
console.error('[user-steps] stderr:', stderr);
// Clean up on error
try {
unlinkSync(scriptPath);
} catch (e) {
// Ignore cleanup errors
}
} else {
console.error('[user-steps] Terminal opened successfully');
}
});
} catch (err) {
console.error('[user-steps] Failed to create temp script:', err);
}
} else if (process.platform === 'win32') {
// Windows: Open new cmd window
exec(`start cmd /k "${command}"`, (error) => {
if (error) {
console.error('[user-steps] Failed to open terminal:', error.message);
}
});
} else {
// Linux: Try common terminal emulators
const terminals = [
`gnome-terminal -- bash -c "${command}; exec bash"`,
`xterm -e "${command}"`,
`konsole -e "${command}"`,
];
const tryTerminal = (index: number) => {
if (index >= terminals.length) {
console.error('[user-steps] No supported terminal emulator found');
return;
}
exec(terminals[index], (error) => {
if (error) {
tryTerminal(index + 1);
}
});
};
tryTerminal(0);
}
}
// Check if a step is blocked by dependencies
function isStepBlocked(step: Step, allSteps: Step[]): boolean {
if (!step.dependsOn || step.dependsOn.length === 0) {
return false;
}
return step.dependsOn.some((depId) => {
const depStep = allSteps.find((s) => s.id === depId);
return depStep && depStep.status !== 'completed';
});
}
// Get the next actionable step (pending and not blocked)
function getNextStep(session: Session): Step | null {
return session.steps.find(
(s) => s.status === 'pending' && !isStepBlocked(s, session.steps)
) ?? null;
}
// Render session as formatted text with current step highlighted
function renderChecklist(session: Session, highlightStepId?: string): string {
const lines: string[] = [];
// Title
lines.push(`**${session.title}**`);
if (session.description) {
lines.push(session.description);
}
lines.push('');
// Steps
for (const step of session.steps) {
const icon = step.status === 'completed' ? '✓'
: step.status === 'skipped' ? '-'
: '○';
const blocked = isStepBlocked(step, session.steps);
const isHighlighted = step.id === highlightStepId;
let line = `${icon} ${step.title}`;
if (!step.required) {
line += ' (optional)';
}
if (blocked && step.status === 'pending') {
line += ' (blocked)';
}
if (isHighlighted) {
line = `→ ${line} ←`;
}
lines.push(line);
// Show description for highlighted step
if (isHighlighted && step.description) {
lines.push(` ${step.description}`);
}
// Show user notes/feedback if present
if (step.notes) {
lines.push(` 💬 ${step.notes}`);
}
}
// Footer
lines.push('');
const completed = session.steps.filter((s) => s.status === 'completed').length;
lines.push(`Progress: ${completed}/${session.steps.length} completed`);
return lines.join('\n');
}
// Define Zod schemas for the MCP tool registration
const StepTypeZod = z.enum(['action', 'verification', 'acknowledgment', 'confirmation']);
const StepZod = z.object({
id: z.string().describe('Unique identifier for the step'),
title: z.string().describe('Short title displayed in checklist'),
description: z.string().optional().describe('Detailed instructions or context'),
type: StepTypeZod.default('action').describe('Type of step'),
required: z.boolean().default(true).describe('Whether step must be completed'),
dependsOn: z.array(z.string()).optional().describe('IDs of steps that must complete first'),
});
const ToolInputZod = {
sessionId: z.string().optional().describe('Optional session ID for resuming a previous session'),
title: z.string().describe('Overall title for the step list'),
description: z.string().optional().describe('Context explaining why these steps are needed'),
steps: z.array(StepZod).describe('List of steps (1-20)'),
allowPartialCompletion: z.boolean().default(false).describe('Allow returning with some steps incomplete'),
timeoutMs: z.number().optional().describe('Timeout in milliseconds (default: no timeout)'),
};
const TOOL_DESCRIPTION = `Present a checklist of steps to the user for manual completion.
Use this tool when you need the user to perform manual actions that you cannot do programmatically, such as:
- Clicking buttons in a browser or GUI application
- Logging into external services
- Physically connecting hardware
- Reviewing and approving changes
- Performing actions that require human verification
This tool automatically opens a new terminal window with an interactive checklist UI where the user can mark steps complete using keyboard controls. The tool also returns a text summary.
Step types:
- action: User must perform a manual action
- verification: User must verify something is correct
- acknowledgment: User must acknowledge they understand
- confirmation: User must confirm to proceed`;
export function registerUserStepsTool(
server: McpServer,
sessionManager: SessionManager
): void {
// Main tool: Create/resume a session and show the checklist
server.tool(
'user_steps',
TOOL_DESCRIPTION,
ToolInputZod,
async (args) => {
const parseResult = UserStepsInputSchema.safeParse(args);
if (!parseResult.success) {
return {
content: [
{
type: 'text' as const,
text: `Invalid input: ${parseResult.error.message}`,
},
],
isError: true,
};
}
const input = parseResult.data;
try {
let session: Session;
if (input.sessionId) {
session = await sessionManager.resume(input.sessionId);
} else {
session = await sessionManager.create({
title: input.title,
description: input.description,
steps: input.steps,
allowPartialCompletion: input.allowPartialCompletion ?? false,
});
}
console.error(`[user-steps] Session ${session.id} created`);
// Automatically open terminal with the interactive UI
openTerminalWithUI(session.id);
// Wait for the user to complete steps in the terminal UI
const watcher = new CompletionWatcher(session.id, sessionManager, {
timeoutMs: input.timeoutMs,
pollIntervalMs: 500,
});
const result = await watcher.waitForCompletion();
// Reload session to get final state
const finalSession = await sessionManager.get(session.id);
const display = finalSession ? renderChecklist(finalSession) : '';
return {
content: [
{
type: 'text' as const,
text: `${display}\n\n✓ Steps ${result.status}: ${result.completedCount}/${result.totalCount} completed.`,
},
],
};
} catch (error) {
if (error instanceof TimeoutError) {
const session = await sessionManager.get(input.sessionId ?? 'unknown');
const result = session
? sessionManager.getResult(session)
: { completedCount: 0, totalCount: input.steps.length };
return {
content: [
{
type: 'text' as const,
text: `Timeout: ${result.completedCount}/${result.totalCount} steps completed before timeout.`,
},
],
};
}
return {
content: [
{
type: 'text' as const,
text: `Error: ${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true,
};
}
}
);
}