feat: Phase 1 - Complete TDD Workflow Automation System (#1289)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Ralph Khreish
2025-10-14 20:25:01 +02:00
parent c92cee72c7
commit 11ace9da1f
83 changed files with 17215 additions and 2342 deletions

54
apps/mcp/package.json Normal file
View File

@@ -0,0 +1,54 @@
{
"name": "@tm/mcp",
"description": "Task Master MCP Tools - TypeScript MCP server tools for AI agent integration",
"type": "module",
"private": true,
"version": "0.28.0-rc.2",
"main": "./dist/index.js",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./tools/autopilot": "./src/tools/autopilot/index.ts"
},
"files": ["dist", "README.md"],
"scripts": {
"typecheck": "tsc --noEmit",
"lint": "biome check src",
"format": "biome format --write src",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:unit": "vitest run -t unit",
"test:integration": "vitest run -t integration",
"test:ci": "vitest run --coverage --reporter=dot"
},
"dependencies": {
"@tm/core": "*",
"zod": "^4.1.11",
"fastmcp": "^3.19.2"
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@types/node": "^22.10.5",
"typescript": "^5.9.2",
"vitest": "^3.2.4"
},
"engines": {
"node": ">=18.0.0"
},
"keywords": [
"task-master",
"mcp",
"mcp-server",
"ai-agent",
"workflow",
"tdd"
],
"author": "",
"license": "MIT",
"typesVersions": {
"*": {
"*": ["src/*"]
}
}
}

8
apps/mcp/src/index.ts Normal file
View File

@@ -0,0 +1,8 @@
/**
* @fileoverview Main entry point for @tm/mcp package
* Exports all MCP tool registration functions
*/
export * from './tools/autopilot/index.js';
export * from './shared/utils.js';
export * from './shared/types.js';

View File

@@ -0,0 +1,36 @@
/**
* Shared types for MCP tools
*/
export interface MCPResponse<T = any> {
success: boolean;
data?: T;
error?: {
code: string;
message: string;
suggestion?: string;
details?: any;
};
version?: {
version: string;
name: string;
};
tag?: {
currentTag: string;
availableTags: string[];
};
}
export interface MCPContext {
log: {
info: (message: string) => void;
warn: (message: string) => void;
error: (message: string) => void;
debug: (message: string) => void;
};
session: any;
}
export interface WithProjectRoot {
projectRoot: string;
}

View File

@@ -0,0 +1,257 @@
/**
* Shared utilities for MCP tools
*/
import type { ContentResult } from 'fastmcp';
import path from 'node:path';
import fs from 'node:fs';
import packageJson from '../../../../package.json' with { type: 'json' };
/**
* Get version information
*/
export function getVersionInfo() {
return {
version: packageJson.version || 'unknown',
name: packageJson.name || 'task-master-ai'
};
}
/**
* Get current tag for a project root
*/
export function getCurrentTag(projectRoot: string): string | null {
try {
// Try to read current tag from state.json
const stateJsonPath = path.join(projectRoot, '.taskmaster', 'state.json');
if (fs.existsSync(stateJsonPath)) {
const stateData = JSON.parse(fs.readFileSync(stateJsonPath, 'utf-8'));
return stateData.currentTag || 'master';
}
return 'master';
} catch {
return null;
}
}
/**
* Handle API result with standardized error handling and response formatting
* This provides a consistent response structure for all MCP tools
*/
export async function handleApiResult<T>(options: {
result: { success: boolean; data?: T; error?: { message: string } };
log?: any;
errorPrefix?: string;
projectRoot?: string;
}): Promise<ContentResult> {
const { result, log, errorPrefix = 'API error', projectRoot } = options;
// Get version info for every response
const versionInfo = getVersionInfo();
// Get current tag if project root is provided
const currentTag = projectRoot ? getCurrentTag(projectRoot) : null;
if (!result.success) {
const errorMsg = result.error?.message || `Unknown ${errorPrefix}`;
log?.error?.(`${errorPrefix}: ${errorMsg}`);
let errorText = `Error: ${errorMsg}\nVersion: ${versionInfo.version}\nName: ${versionInfo.name}`;
if (currentTag) {
errorText += `\nCurrent Tag: ${currentTag}`;
}
return {
content: [
{
type: 'text',
text: errorText
}
],
isError: true
};
}
log?.info?.('Successfully completed operation');
// Create the response payload including version info and tag
const responsePayload: any = {
data: result.data,
version: versionInfo
};
// Add current tag if available
if (currentTag) {
responsePayload.tag = currentTag;
}
return {
content: [
{
type: 'text',
text: JSON.stringify(responsePayload, null, 2)
}
]
};
}
/**
* Normalize project root path (handles URI encoding, file:// protocol, Windows paths)
*/
export function normalizeProjectRoot(rawPath: string): string {
if (!rawPath) return process.cwd();
try {
let pathString = rawPath;
// Decode URI encoding
try {
pathString = decodeURIComponent(pathString);
} catch {
// If decoding fails, use as-is
}
// Strip file:// prefix
if (pathString.startsWith('file:///')) {
pathString = pathString.slice(7);
} else if (pathString.startsWith('file://')) {
pathString = pathString.slice(7);
}
// Handle Windows drive letter after stripping prefix (e.g., /C:/...)
if (
pathString.startsWith('/') &&
/[A-Za-z]:/.test(pathString.substring(1, 3))
) {
pathString = pathString.substring(1);
}
// Normalize backslashes to forward slashes
pathString = pathString.replace(/\\/g, '/');
// Resolve to absolute path
return path.resolve(pathString);
} catch {
return path.resolve(rawPath);
}
}
/**
* Get project root from session object
*/
function getProjectRootFromSession(session: any): string | null {
try {
// Check primary location
if (session?.roots?.[0]?.uri) {
return normalizeProjectRoot(session.roots[0].uri);
}
// Check alternate location
else if (session?.roots?.roots?.[0]?.uri) {
return normalizeProjectRoot(session.roots.roots[0].uri);
}
return null;
} catch {
return null;
}
}
/**
* Wrapper to normalize project root in args with proper precedence order
*
* PRECEDENCE ORDER:
* 1. TASK_MASTER_PROJECT_ROOT environment variable (from process.env or session)
* 2. args.projectRoot (explicitly provided)
* 3. Session-based project root resolution
* 4. Current directory fallback
*/
export function withNormalizedProjectRoot<T extends { projectRoot?: string }>(
fn: (
args: T & { projectRoot: string },
context: any
) => Promise<ContentResult>
): (args: T, context: any) => Promise<ContentResult> {
return async (args: T, context: any): Promise<ContentResult> => {
const { log, session } = context;
let normalizedRoot: string | null = null;
let rootSource = 'unknown';
try {
// 1. Check for TASK_MASTER_PROJECT_ROOT environment variable first
if (process.env.TASK_MASTER_PROJECT_ROOT) {
const envRoot = process.env.TASK_MASTER_PROJECT_ROOT;
normalizedRoot = path.isAbsolute(envRoot)
? envRoot
: path.resolve(process.cwd(), envRoot);
rootSource = 'TASK_MASTER_PROJECT_ROOT environment variable';
log?.info?.(`Using project root from ${rootSource}: ${normalizedRoot}`);
}
// Also check session environment variables for TASK_MASTER_PROJECT_ROOT
else if (session?.env?.TASK_MASTER_PROJECT_ROOT) {
const envRoot = session.env.TASK_MASTER_PROJECT_ROOT;
normalizedRoot = path.isAbsolute(envRoot)
? envRoot
: path.resolve(process.cwd(), envRoot);
rootSource = 'TASK_MASTER_PROJECT_ROOT session environment variable';
log?.info?.(`Using project root from ${rootSource}: ${normalizedRoot}`);
}
// 2. If no environment variable, try args.projectRoot
else if (args.projectRoot) {
normalizedRoot = normalizeProjectRoot(args.projectRoot);
rootSource = 'args.projectRoot';
log?.info?.(`Using project root from ${rootSource}: ${normalizedRoot}`);
}
// 3. If no args.projectRoot, try session-based resolution
else {
const sessionRoot = getProjectRootFromSession(session);
if (sessionRoot) {
normalizedRoot = sessionRoot;
rootSource = 'session';
log?.info?.(
`Using project root from ${rootSource}: ${normalizedRoot}`
);
}
}
if (!normalizedRoot) {
log?.error?.(
'Could not determine project root from environment, args, or session.'
);
return handleApiResult({
result: {
success: false,
error: {
message:
'Could not determine project root. Please provide projectRoot argument or ensure TASK_MASTER_PROJECT_ROOT environment variable is set.'
}
}
});
}
// Inject the normalized root back into args
const updatedArgs = { ...args, projectRoot: normalizedRoot } as T & {
projectRoot: string;
};
// Execute the original function with normalized root in args
return await fn(updatedArgs, context);
} catch (error: any) {
log?.error?.(
`Error within withNormalizedProjectRoot HOF (Normalized Root: ${normalizedRoot}): ${error.message}`
);
if (error.stack && log?.debug) {
log.debug(error.stack);
}
return handleApiResult({
result: {
success: false,
error: {
message: `Operation failed: ${error.message}`
}
}
});
}
};
}

View File

@@ -0,0 +1,99 @@
/**
* @fileoverview autopilot-abort MCP tool
* Abort a running TDD workflow and clean up state
*/
import { z } from 'zod';
import {
handleApiResult,
withNormalizedProjectRoot
} from '../../shared/utils.js';
import type { MCPContext } from '../../shared/types.js';
import { WorkflowService } from '@tm/core';
import type { FastMCP } from 'fastmcp';
const AbortSchema = z.object({
projectRoot: z
.string()
.describe('Absolute path to the project root directory')
});
type AbortArgs = z.infer<typeof AbortSchema>;
/**
* Register the autopilot_abort tool with the MCP server
*/
export function registerAutopilotAbortTool(server: FastMCP) {
server.addTool({
name: 'autopilot_abort',
description:
'Abort the current TDD workflow and clean up workflow state. This will remove the workflow state file but will NOT delete the git branch or any code changes.',
parameters: AbortSchema,
execute: withNormalizedProjectRoot(
async (args: AbortArgs, context: MCPContext) => {
const { projectRoot } = args;
try {
context.log.info(`Aborting autopilot workflow in ${projectRoot}`);
const workflowService = new WorkflowService(projectRoot);
// Check if workflow exists
const hasWorkflow = await workflowService.hasWorkflow();
if (!hasWorkflow) {
context.log.warn('No active workflow to abort');
return handleApiResult({
result: {
success: true,
data: {
message: 'No active workflow to abort',
hadWorkflow: false
}
},
log: context.log,
projectRoot
});
}
// Get info before aborting
await workflowService.resumeWorkflow();
const status = workflowService.getStatus();
// Abort workflow
await workflowService.abortWorkflow();
context.log.info('Workflow state deleted');
return handleApiResult({
result: {
success: true,
data: {
message: 'Workflow aborted',
hadWorkflow: true,
taskId: status.taskId,
branchName: status.branchName,
note: 'Git branch and code changes were preserved. You can manually clean them up if needed.'
}
},
log: context.log,
projectRoot
});
} catch (error: any) {
context.log.error(`Error in autopilot-abort: ${error.message}`);
if (error.stack) {
context.log.debug(error.stack);
}
return handleApiResult({
result: {
success: false,
error: { message: `Failed to abort workflow: ${error.message}` }
},
log: context.log,
projectRoot
});
}
}
)
});
}

View File

@@ -0,0 +1,240 @@
/**
* @fileoverview autopilot-commit MCP tool
* Create a git commit with automatic staging and message generation
*/
import { z } from 'zod';
import {
handleApiResult,
withNormalizedProjectRoot
} from '../../shared/utils.js';
import type { MCPContext } from '../../shared/types.js';
import { WorkflowService, GitAdapter, CommitMessageGenerator } from '@tm/core';
import type { FastMCP } from 'fastmcp';
const CommitSchema = z.object({
projectRoot: z
.string()
.describe('Absolute path to the project root directory'),
files: z
.array(z.string())
.optional()
.describe(
'Specific files to stage (relative to project root). If not provided, stages all changes.'
),
customMessage: z
.string()
.optional()
.describe('Custom commit message to use instead of auto-generated message')
});
type CommitArgs = z.infer<typeof CommitSchema>;
/**
* Register the autopilot_commit tool with the MCP server
*/
export function registerAutopilotCommitTool(server: FastMCP) {
server.addTool({
name: 'autopilot_commit',
description:
'Create a git commit with automatic staging, message generation, and metadata embedding. Generates appropriate commit messages based on subtask context and TDD phase.',
parameters: CommitSchema,
execute: withNormalizedProjectRoot(
async (args: CommitArgs, context: MCPContext) => {
const { projectRoot, files, customMessage } = args;
try {
context.log.info(`Creating commit for workflow in ${projectRoot}`);
const workflowService = new WorkflowService(projectRoot);
// Check if workflow exists
if (!(await workflowService.hasWorkflow())) {
return handleApiResult({
result: {
success: false,
error: {
message:
'No active workflow found. Start a workflow with autopilot_start'
}
},
log: context.log,
projectRoot
});
}
// Resume workflow
await workflowService.resumeWorkflow();
const status = workflowService.getStatus();
const workflowContext = workflowService.getContext();
// Verify we're in COMMIT phase
if (status.tddPhase !== 'COMMIT') {
context.log.warn(
`Not in COMMIT phase (currently in ${status.tddPhase})`
);
return handleApiResult({
result: {
success: false,
error: {
message: `Cannot commit: currently in ${status.tddPhase} phase. Complete the ${status.tddPhase} phase first using autopilot_complete_phase`
}
},
log: context.log,
projectRoot
});
}
// Verify there's an active subtask
if (!status.currentSubtask) {
return handleApiResult({
result: {
success: false,
error: { message: 'No active subtask to commit' }
},
log: context.log,
projectRoot
});
}
// Initialize git adapter
const gitAdapter = new GitAdapter(projectRoot);
// Stage files
try {
if (files && files.length > 0) {
await gitAdapter.stageFiles(files);
context.log.info(`Staged ${files.length} files`);
} else {
await gitAdapter.stageFiles(['.']);
context.log.info('Staged all changes');
}
} catch (error: any) {
context.log.error(`Failed to stage files: ${error.message}`);
return handleApiResult({
result: {
success: false,
error: { message: `Failed to stage files: ${error.message}` }
},
log: context.log,
projectRoot
});
}
// Check if there are staged changes
const hasStagedChanges = await gitAdapter.hasStagedChanges();
if (!hasStagedChanges) {
context.log.warn('No staged changes to commit');
return handleApiResult({
result: {
success: false,
error: {
message:
'No staged changes to commit. Make code changes before committing'
}
},
log: context.log,
projectRoot
});
}
// Get git status for message generation
const gitStatus = await gitAdapter.getStatus();
// Generate commit message
let commitMessage: string;
if (customMessage) {
commitMessage = customMessage;
context.log.info('Using custom commit message');
} else {
const messageGenerator = new CommitMessageGenerator();
// Determine commit type based on phase and subtask
// RED phase = test files, GREEN phase = implementation
const type = status.tddPhase === 'COMMIT' ? 'feat' : 'test';
// Use subtask title as description
const description = status.currentSubtask.title;
// Construct proper CommitMessageOptions
const options = {
type,
description,
changedFiles: gitStatus.staged,
taskId: status.taskId,
phase: status.tddPhase,
testsPassing: workflowContext.lastTestResults?.passed,
testsFailing: workflowContext.lastTestResults?.failed
};
commitMessage = messageGenerator.generateMessage(options);
context.log.info('Generated commit message automatically');
}
// Create commit
try {
await gitAdapter.createCommit(commitMessage);
context.log.info('Commit created successfully');
} catch (error: any) {
context.log.error(`Failed to create commit: ${error.message}`);
return handleApiResult({
result: {
success: false,
error: { message: `Failed to create commit: ${error.message}` }
},
log: context.log,
projectRoot
});
}
// Get last commit info
const lastCommit = await gitAdapter.getLastCommit();
// Complete COMMIT phase and advance workflow
const newStatus = await workflowService.commit();
context.log.info(
`Commit completed. Current phase: ${newStatus.tddPhase || newStatus.phase}`
);
const isComplete = newStatus.phase === 'COMPLETE';
// Get next action with guidance
const nextAction = workflowService.getNextAction();
return handleApiResult({
result: {
success: true,
data: {
message: isComplete
? 'Workflow completed successfully'
: 'Commit created and workflow advanced',
commitSha: lastCommit.sha,
commitMessage,
...newStatus,
isComplete,
nextAction: nextAction.action,
nextSteps: nextAction.nextSteps
}
},
log: context.log,
projectRoot
});
} catch (error: any) {
context.log.error(`Error in autopilot-commit: ${error.message}`);
if (error.stack) {
context.log.debug(error.stack);
}
return handleApiResult({
result: {
success: false,
error: { message: `Failed to commit: ${error.message}` }
},
log: context.log,
projectRoot
});
}
}
)
});
}

View File

@@ -0,0 +1,152 @@
/**
* @fileoverview autopilot-complete MCP tool
* Complete the current TDD phase with test result validation
*/
import { z } from 'zod';
import {
handleApiResult,
withNormalizedProjectRoot
} from '../../shared/utils.js';
import type { MCPContext } from '../../shared/types.js';
import { WorkflowService } from '@tm/core';
import type { FastMCP } from 'fastmcp';
const CompletePhaseSchema = z.object({
projectRoot: z
.string()
.describe('Absolute path to the project root directory'),
testResults: z
.object({
total: z.number().describe('Total number of tests'),
passed: z.number().describe('Number of passing tests'),
failed: z.number().describe('Number of failing tests'),
skipped: z.number().optional().describe('Number of skipped tests')
})
.describe('Test results from running the test suite')
});
type CompletePhaseArgs = z.infer<typeof CompletePhaseSchema>;
/**
* Register the autopilot_complete_phase tool with the MCP server
*/
export function registerAutopilotCompleteTool(server: FastMCP) {
server.addTool({
name: 'autopilot_complete_phase',
description:
'Complete the current TDD phase (RED, GREEN, or COMMIT) with test result validation. RED phase: expects failures (if 0 failures, feature is already implemented and subtask auto-completes). GREEN phase: expects all tests passing.',
parameters: CompletePhaseSchema,
execute: withNormalizedProjectRoot(
async (args: CompletePhaseArgs, context: MCPContext) => {
const { projectRoot, testResults } = args;
try {
context.log.info(
`Completing current phase in workflow for ${projectRoot}`
);
const workflowService = new WorkflowService(projectRoot);
// Check if workflow exists
if (!(await workflowService.hasWorkflow())) {
return handleApiResult({
result: {
success: false,
error: {
message:
'No active workflow found. Start a workflow with autopilot_start'
}
},
log: context.log,
projectRoot
});
}
// Resume workflow to get current state
await workflowService.resumeWorkflow();
const currentStatus = workflowService.getStatus();
// Validate that we're in a TDD phase (RED or GREEN)
if (!currentStatus.tddPhase) {
return handleApiResult({
result: {
success: false,
error: {
message: `Cannot complete phase: not in a TDD phase (current phase: ${currentStatus.phase})`
}
},
log: context.log,
projectRoot
});
}
// COMMIT phase completion is handled by autopilot_commit tool
if (currentStatus.tddPhase === 'COMMIT') {
return handleApiResult({
result: {
success: false,
error: {
message:
'Cannot complete COMMIT phase with this tool. Use autopilot_commit instead'
}
},
log: context.log,
projectRoot
});
}
// Map TDD phase to TestResult phase (only RED or GREEN allowed)
const phase = currentStatus.tddPhase as 'RED' | 'GREEN';
// Construct full TestResult with phase
const fullTestResults = {
total: testResults.total,
passed: testResults.passed,
failed: testResults.failed,
skipped: testResults.skipped ?? 0,
phase
};
// Complete phase with test results
const status = await workflowService.completePhase(fullTestResults);
const nextAction = workflowService.getNextAction();
context.log.info(
`Phase completed. New phase: ${status.tddPhase || status.phase}`
);
return handleApiResult({
result: {
success: true,
data: {
message: `Phase completed. Transitioned to ${status.tddPhase || status.phase}`,
...status,
nextAction: nextAction.action,
actionDescription: nextAction.description,
nextSteps: nextAction.nextSteps
}
},
log: context.log,
projectRoot
});
} catch (error: any) {
context.log.error(`Error in autopilot-complete: ${error.message}`);
if (error.stack) {
context.log.debug(error.stack);
}
return handleApiResult({
result: {
success: false,
error: {
message: `Failed to complete phase: ${error.message}`
}
},
log: context.log,
projectRoot
});
}
}
)
});
}

View File

@@ -0,0 +1,114 @@
/**
* @fileoverview autopilot-finalize MCP tool
* Finalize and complete the workflow with working tree validation
*/
import { z } from 'zod';
import {
handleApiResult,
withNormalizedProjectRoot
} from '../../shared/utils.js';
import type { MCPContext } from '../../shared/types.js';
import { WorkflowService } from '@tm/core';
import type { FastMCP } from 'fastmcp';
const FinalizeSchema = z.object({
projectRoot: z
.string()
.describe('Absolute path to the project root directory')
});
type FinalizeArgs = z.infer<typeof FinalizeSchema>;
/**
* Register the autopilot_finalize tool with the MCP server
*/
export function registerAutopilotFinalizeTool(server: FastMCP) {
server.addTool({
name: 'autopilot_finalize',
description:
'Finalize and complete the workflow. Validates that all changes are committed and working tree is clean before marking workflow as complete.',
parameters: FinalizeSchema,
execute: withNormalizedProjectRoot(
async (args: FinalizeArgs, context: MCPContext) => {
const { projectRoot } = args;
try {
context.log.info(`Finalizing workflow in ${projectRoot}`);
const workflowService = new WorkflowService(projectRoot);
// Check if workflow exists
if (!(await workflowService.hasWorkflow())) {
return handleApiResult({
result: {
success: false,
error: {
message:
'No active workflow found. Start a workflow with autopilot_start'
}
},
log: context.log,
projectRoot
});
}
// Resume workflow
await workflowService.resumeWorkflow();
const currentStatus = workflowService.getStatus();
// Verify we're in FINALIZE phase
if (currentStatus.phase !== 'FINALIZE') {
return handleApiResult({
result: {
success: false,
error: {
message: `Cannot finalize: workflow is in ${currentStatus.phase} phase. Complete all subtasks first.`
}
},
log: context.log,
projectRoot
});
}
// Finalize workflow (validates clean working tree)
const newStatus = await workflowService.finalizeWorkflow();
context.log.info('Workflow finalized successfully');
// Get next action
const nextAction = workflowService.getNextAction();
return handleApiResult({
result: {
success: true,
data: {
message: 'Workflow completed successfully',
...newStatus,
nextAction: nextAction.action,
nextSteps: nextAction.nextSteps
}
},
log: context.log,
projectRoot
});
} catch (error: any) {
context.log.error(`Error in autopilot-finalize: ${error.message}`);
if (error.stack) {
context.log.debug(error.stack);
}
return handleApiResult({
result: {
success: false,
error: {
message: `Failed to finalize workflow: ${error.message}`
}
},
log: context.log,
projectRoot
});
}
}
)
});
}

View File

@@ -0,0 +1,13 @@
/**
* @fileoverview Autopilot MCP tools index
* Exports all autopilot tool registration functions
*/
export { registerAutopilotStartTool } from './start.tool.js';
export { registerAutopilotResumeTool } from './resume.tool.js';
export { registerAutopilotNextTool } from './next.tool.js';
export { registerAutopilotStatusTool } from './status.tool.js';
export { registerAutopilotCompleteTool } from './complete.tool.js';
export { registerAutopilotCommitTool } from './commit.tool.js';
export { registerAutopilotFinalizeTool } from './finalize.tool.js';
export { registerAutopilotAbortTool } from './abort.tool.js';

View File

@@ -0,0 +1,99 @@
/**
* @fileoverview autopilot-next MCP tool
* Get the next action to perform in the TDD workflow
*/
import { z } from 'zod';
import {
handleApiResult,
withNormalizedProjectRoot
} from '../../shared/utils.js';
import type { MCPContext } from '../../shared/types.js';
import { WorkflowService } from '@tm/core';
import type { FastMCP } from 'fastmcp';
const NextActionSchema = z.object({
projectRoot: z
.string()
.describe('Absolute path to the project root directory')
});
type NextActionArgs = z.infer<typeof NextActionSchema>;
/**
* Register the autopilot_next tool with the MCP server
*/
export function registerAutopilotNextTool(server: FastMCP) {
server.addTool({
name: 'autopilot_next',
description:
'Get the next action to perform in the TDD workflow. Returns detailed context about what needs to be done next, including the current phase, subtask, and expected actions.',
parameters: NextActionSchema,
execute: withNormalizedProjectRoot(
async (args: NextActionArgs, context: MCPContext) => {
const { projectRoot } = args;
try {
context.log.info(
`Getting next action for workflow in ${projectRoot}`
);
const workflowService = new WorkflowService(projectRoot);
// Check if workflow exists
if (!(await workflowService.hasWorkflow())) {
return handleApiResult({
result: {
success: false,
error: {
message:
'No active workflow found. Start a workflow with autopilot_start'
}
},
log: context.log,
projectRoot
});
}
// Resume to load state
await workflowService.resumeWorkflow();
// Get next action
const nextAction = workflowService.getNextAction();
const status = workflowService.getStatus();
context.log.info(`Next action determined: ${nextAction.action}`);
return handleApiResult({
result: {
success: true,
data: {
action: nextAction.action,
actionDescription: nextAction.description,
...status,
nextSteps: nextAction.nextSteps
}
},
log: context.log,
projectRoot
});
} catch (error: any) {
context.log.error(`Error in autopilot-next: ${error.message}`);
if (error.stack) {
context.log.debug(error.stack);
}
return handleApiResult({
result: {
success: false,
error: {
message: `Failed to get next action: ${error.message}`
}
},
log: context.log,
projectRoot
});
}
}
)
});
}

View File

@@ -0,0 +1,95 @@
/**
* @fileoverview autopilot-resume MCP tool
* Resume a previously started TDD workflow from saved state
*/
import { z } from 'zod';
import {
handleApiResult,
withNormalizedProjectRoot
} from '../../shared/utils.js';
import type { MCPContext } from '../../shared/types.js';
import { WorkflowService } from '@tm/core';
import type { FastMCP } from 'fastmcp';
const ResumeWorkflowSchema = z.object({
projectRoot: z
.string()
.describe('Absolute path to the project root directory')
});
type ResumeWorkflowArgs = z.infer<typeof ResumeWorkflowSchema>;
/**
* Register the autopilot_resume tool with the MCP server
*/
export function registerAutopilotResumeTool(server: FastMCP) {
server.addTool({
name: 'autopilot_resume',
description:
'Resume a previously started TDD workflow from saved state. Restores the workflow state machine and continues from where it left off.',
parameters: ResumeWorkflowSchema,
execute: withNormalizedProjectRoot(
async (args: ResumeWorkflowArgs, context: MCPContext) => {
const { projectRoot } = args;
try {
context.log.info(`Resuming autopilot workflow in ${projectRoot}`);
const workflowService = new WorkflowService(projectRoot);
// Check if workflow exists
if (!(await workflowService.hasWorkflow())) {
return handleApiResult({
result: {
success: false,
error: {
message:
'No workflow state found. Start a new workflow with autopilot_start'
}
},
log: context.log,
projectRoot
});
}
// Resume workflow
const status = await workflowService.resumeWorkflow();
const nextAction = workflowService.getNextAction();
context.log.info(
`Workflow resumed successfully for task ${status.taskId}`
);
return handleApiResult({
result: {
success: true,
data: {
message: 'Workflow resumed',
...status,
nextAction: nextAction.action,
actionDescription: nextAction.description,
nextSteps: nextAction.nextSteps
}
},
log: context.log,
projectRoot
});
} catch (error: any) {
context.log.error(`Error in autopilot-resume: ${error.message}`);
if (error.stack) {
context.log.debug(error.stack);
}
return handleApiResult({
result: {
success: false,
error: { message: `Failed to resume workflow: ${error.message}` }
},
log: context.log,
projectRoot
});
}
}
)
});
}

View File

@@ -0,0 +1,197 @@
/**
* @fileoverview autopilot-start MCP tool
* Initialize and start a new TDD workflow for a task
*/
import { z } from 'zod';
import {
handleApiResult,
withNormalizedProjectRoot
} from '../../shared/utils.js';
import type { MCPContext } from '../../shared/types.js';
import { createTaskMasterCore } from '@tm/core';
import { WorkflowService } from '@tm/core';
import type { FastMCP } from 'fastmcp';
const StartWorkflowSchema = z.object({
taskId: z
.string()
.describe(
'Main task ID to start workflow for (e.g., "1", "2", "HAM-123"). Subtask IDs (e.g., "2.3", "1.1") are not allowed.'
),
projectRoot: z
.string()
.describe('Absolute path to the project root directory'),
maxAttempts: z
.number()
.optional()
.default(3)
.describe('Maximum attempts per subtask (default: 3)'),
force: z
.boolean()
.optional()
.default(false)
.describe('Force start even if workflow state exists')
});
type StartWorkflowArgs = z.infer<typeof StartWorkflowSchema>;
/**
* Check if a task ID is a main task (not a subtask)
* Main tasks: "1", "2", "HAM-123", "PROJ-456"
* Subtasks: "1.1", "2.3", "HAM-123.1"
*/
function isMainTaskId(taskId: string): boolean {
// A main task has no dots in the ID after the optional prefix
// Examples: "1" ✓, "HAM-123" ✓, "1.1" ✗, "HAM-123.1" ✗
const parts = taskId.split('.');
return parts.length === 1;
}
/**
* Register the autopilot_start tool with the MCP server
*/
export function registerAutopilotStartTool(server: FastMCP) {
server.addTool({
name: 'autopilot_start',
description:
'Initialize and start a new TDD workflow for a task. Creates a git branch and sets up the workflow state machine.',
parameters: StartWorkflowSchema,
execute: withNormalizedProjectRoot(
async (args: StartWorkflowArgs, context: MCPContext) => {
const { taskId, projectRoot, maxAttempts, force } = args;
try {
context.log.info(
`Starting autopilot workflow for task ${taskId} in ${projectRoot}`
);
// Validate that taskId is a main task (not a subtask)
if (!isMainTaskId(taskId)) {
return handleApiResult({
result: {
success: false,
error: {
message: `Task ID "${taskId}" is a subtask. Autopilot workflows can only be started for main tasks (e.g., "1", "2", "HAM-123"). Please provide the parent task ID instead.`
}
},
log: context.log,
projectRoot
});
}
// Load task data and get current tag
const core = await createTaskMasterCore({
projectPath: projectRoot
});
// Get current tag from ConfigManager
const currentTag = core.getActiveTag();
const taskResult = await core.getTaskWithSubtask(taskId);
if (!taskResult || !taskResult.task) {
await core.close();
return handleApiResult({
result: {
success: false,
error: { message: `Task ${taskId} not found` }
},
log: context.log,
projectRoot
});
}
const task = taskResult.task;
// Validate task has subtasks
if (!task.subtasks || task.subtasks.length === 0) {
await core.close();
return handleApiResult({
result: {
success: false,
error: {
message: `Task ${taskId} has no subtasks. Please use expand_task (with id="${taskId}") to create subtasks first. For improved results, consider running analyze_complexity before expanding the task.`
}
},
log: context.log,
projectRoot
});
}
// Initialize workflow service
const workflowService = new WorkflowService(projectRoot);
// Check for existing workflow
const hasWorkflow = await workflowService.hasWorkflow();
if (hasWorkflow && !force) {
context.log.warn('Workflow state already exists');
return handleApiResult({
result: {
success: false,
error: {
message:
'Workflow already in progress. Use force=true to override or resume the existing workflow. Suggestion: Use autopilot_resume to continue the existing workflow'
}
},
log: context.log,
projectRoot
});
}
// Start workflow
const status = await workflowService.startWorkflow({
taskId,
taskTitle: task.title,
subtasks: task.subtasks.map((st: any) => ({
id: st.id,
title: st.title,
status: st.status,
maxAttempts
})),
maxAttempts,
force,
tag: currentTag // Pass current tag for branch naming
});
context.log.info(`Workflow started successfully for task ${taskId}`);
// Get next action with guidance from WorkflowService
const nextAction = workflowService.getNextAction();
return handleApiResult({
result: {
success: true,
data: {
message: `Workflow started for task ${taskId}`,
taskId,
branchName: status.branchName,
phase: status.phase,
tddPhase: status.tddPhase,
progress: status.progress,
currentSubtask: status.currentSubtask,
nextAction: nextAction.action,
nextSteps: nextAction.nextSteps
}
},
log: context.log,
projectRoot
});
} catch (error: any) {
context.log.error(`Error in autopilot-start: ${error.message}`);
if (error.stack) {
context.log.debug(error.stack);
}
return handleApiResult({
result: {
success: false,
error: { message: `Failed to start workflow: ${error.message}` }
},
log: context.log,
projectRoot
});
}
}
)
});
}

View File

@@ -0,0 +1,93 @@
/**
* @fileoverview autopilot-status MCP tool
* Get comprehensive workflow status and progress information
*/
import { z } from 'zod';
import {
handleApiResult,
withNormalizedProjectRoot
} from '../../shared/utils.js';
import type { MCPContext } from '../../shared/types.js';
import { WorkflowService } from '@tm/core';
import type { FastMCP } from 'fastmcp';
const StatusSchema = z.object({
projectRoot: z
.string()
.describe('Absolute path to the project root directory')
});
type StatusArgs = z.infer<typeof StatusSchema>;
/**
* Register the autopilot_status tool with the MCP server
*/
export function registerAutopilotStatusTool(server: FastMCP) {
server.addTool({
name: 'autopilot_status',
description:
'Get comprehensive workflow status including current phase, progress, subtask details, and activity history.',
parameters: StatusSchema,
execute: withNormalizedProjectRoot(
async (args: StatusArgs, context: MCPContext) => {
const { projectRoot } = args;
try {
context.log.info(`Getting workflow status for ${projectRoot}`);
const workflowService = new WorkflowService(projectRoot);
// Check if workflow exists
if (!(await workflowService.hasWorkflow())) {
return handleApiResult({
result: {
success: false,
error: {
message:
'No active workflow found. Start a workflow with autopilot_start'
}
},
log: context.log,
projectRoot
});
}
// Resume to load state
await workflowService.resumeWorkflow();
// Get status
const status = workflowService.getStatus();
context.log.info(
`Workflow status retrieved for task ${status.taskId}`
);
return handleApiResult({
result: {
success: true,
data: status
},
log: context.log,
projectRoot
});
} catch (error: any) {
context.log.error(`Error in autopilot-status: ${error.message}`);
if (error.stack) {
context.log.debug(error.stack);
}
return handleApiResult({
result: {
success: false,
error: {
message: `Failed to get workflow status: ${error.message}`
}
},
log: context.log,
projectRoot
});
}
}
)
});
}

36
apps/mcp/tsconfig.json Normal file
View File

@@ -0,0 +1,36 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"lib": ["ES2022"],
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"baseUrl": ".",
"rootDir": "./src",
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "NodeNext",
"moduleDetection": "force",
"types": ["node"],
"resolveJsonModule": true,
"isolatedModules": true,
"allowImportingTsExtensions": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests", "**/*.test.ts", "**/*.spec.ts"]
}

23
apps/mcp/vitest.config.ts Normal file
View File

@@ -0,0 +1,23 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'dist/',
'tests/',
'**/*.test.ts',
'**/*.spec.ts',
'**/*.d.ts',
'**/mocks/**',
'**/fixtures/**',
'vitest.config.ts'
]
}
}
});