/**
* @fileoverview Flutter Tools for MCP Mobile Server
*
* This module provides comprehensive Flutter development tools for the MCP protocol,
* enabling AI agents to interact with Flutter SDK, manage devices, run development
* sessions, execute builds, and manage the complete Flutter development lifecycle.
*
* @module tools/flutter
* @category Core Tools
*
* Key Features:
* - Environment diagnostics (flutter doctor, version)
* - Device and emulator management
* - Development session management with hot reload
* - Build automation for multiple platforms
* - Testing with coverage support
* - Project maintenance (clean, pub get)
* - Screenshot capture
*
* @example
* ```typescript
* const flutterTools = createFlutterTools(globalProcessMap);
* const doctorTool = flutterTools.get('flutter_doctor');
* const result = await doctorTool.handler({});
* ```
*/
import { z } from 'zod';
import { processExecutor } from '../utils/process.js';
import path from 'path';
import fs from 'fs/promises';
import { spawn } from 'child_process';
/**
* Global map tracking active Flutter run sessions.
* Maps session IDs to session metadata including process ID, device ID, and project path.
*
* @type {Map<string, {pid: number, deviceId: string, projectPath: string}>}
*/
let runningFlutterSessions: Map<string, { pid: number; deviceId: string; projectPath: string }>;
/**
* Zod validation schema for flutter_launch_emulator tool.
* Validates emulator ID parameter.
*
* @type {z.ZodObject}
*/
const FlutterEmulatorLaunchSchema = z.object({
emulatorId: z.string().min(1),
});
/**
* Zod validation schema for flutter_run tool.
* Validates Flutter development session parameters.
*
* @type {z.ZodObject}
* @property {string} cwd - Working directory (Flutter project root)
* @property {string} [deviceId] - Target device ID
* @property {string} [target] - Target dart file (e.g., lib/main.dart)
* @property {string} [flavor] - Build flavor
* @property {number} [debugPort] - Debug port (1024-65535)
*/
const FlutterRunSchema = z.object({
cwd: z.string().min(1),
deviceId: z.string().optional(),
target: z.string().optional(),
flavor: z.string().optional(),
debugPort: z.number().min(1024).max(65535).optional(),
});
/**
* Zod validation schema for flutter_build tool.
* Validates Flutter build parameters for multiple platforms.
*
* @type {z.ZodObject}
* @property {string} cwd - Working directory (Flutter project root)
* @property {string} target - Build target (apk, appbundle, ipa, ios, android, web, etc.)
* @property {string} buildMode - Build mode (debug, profile, release)
* @property {string} [flavor] - Build flavor
*/
const FlutterBuildSchema = z.object({
cwd: z.string().min(1),
target: z.enum(['apk', 'appbundle', 'ipa', 'ios', 'android', 'web', 'windows', 'macos', 'linux']),
buildMode: z.enum(['debug', 'profile', 'release']).default('debug'),
flavor: z.string().optional(),
});
/**
* Zod validation schema for flutter_test tool.
* Validates Flutter test execution parameters.
*
* @type {z.ZodObject}
* @property {string} cwd - Working directory (Flutter project root)
* @property {string} [testFile] - Specific test file to run
* @property {boolean} coverage - Enable test coverage (default: false)
*/
const FlutterTestSchema = z.object({
cwd: z.string().min(1),
testFile: z.string().optional(),
coverage: z.boolean().default(false),
});
/**
* Zod validation schema for flutter_clean tool.
*
* @type {z.ZodObject}
* @property {string} cwd - Working directory (Flutter project root)
*/
const FlutterCleanSchema = z.object({
cwd: z.string().min(1),
});
/**
* Zod validation schema for flutter_pub_get tool.
*
* @type {z.ZodObject}
* @property {string} cwd - Working directory (Flutter project root)
*/
const FlutterPubGetSchema = z.object({
cwd: z.string().min(1),
});
/**
* Zod validation schema for flutter_screenshot tool.
*
* @type {z.ZodObject}
* @property {string} [deviceId] - Target device ID
* @property {string} [outputPath] - Output PNG file path
*/
const FlutterScreenshotSchema = z.object({
deviceId: z.string().optional(),
outputPath: z.string().optional(),
});
/**
* Zod validation schema for flutter_stop_session tool.
*
* @type {z.ZodObject}
* @property {string} sessionId - Flutter session ID to stop
*/
const FlutterStopSessionSchema = z.object({
sessionId: z.string().min(1),
});
/**
* Validates that a directory is a valid Flutter project.
* Checks for pubspec.yaml file and flutter section.
*
* @param {string} cwd - Directory path to validate
* @returns {Promise<void>}
* @throws {Error} If pubspec.yaml not found or flutter section missing
*
* @example
* ```typescript
* await validateFlutterProject('/path/to/flutter/project');
* ```
*/
const validateFlutterProject = async (cwd: string): Promise<void> => {
const pubspecPath = path.join(cwd, 'pubspec.yaml');
try {
await fs.access(pubspecPath);
const pubspecContent = await fs.readFile(pubspecPath, 'utf8');
if (!pubspecContent.includes('flutter:')) {
throw new Error(`Directory does not appear to be a Flutter project. No flutter section found in ${pubspecPath}`);
}
} catch {
throw new Error(`pubspec.yaml not found. Flutter project must contain pubspec.yaml at: ${pubspecPath}`);
}
};
/**
* Creates and configures all Flutter tools for the MCP Mobile Server.
*
* This factory function initializes and returns a complete set of Flutter development tools,
* providing comprehensive Flutter SDK integration for AI agents via the MCP protocol.
*
* **Tools Created:**
* - `flutter_doctor`: System diagnostics and environment validation
* - `flutter_version`: Get Flutter SDK version information
* - `flutter_list_devices`: List connected devices and emulators
* - `flutter_list_emulators`: List available emulators
* - `flutter_launch_emulator`: Launch a Flutter emulator
* - `flutter_run`: Start development session with hot reload
* - `flutter_stop_session`: Stop running development session
* - `flutter_list_sessions`: List active development sessions
* - `flutter_build`: Build app for target platform
* - `flutter_test`: Run tests with optional coverage
* - `flutter_clean`: Clean build cache
* - `flutter_pub_get`: Install project dependencies
* - `flutter_screenshot`: Capture screenshot from device
*
* **Features:**
* - Session management with hot reload support
* - Multi-platform build support (Android, iOS, Web, Desktop)
* - Comprehensive error handling and validation
* - Process lifecycle tracking
* - Automatic fallback for machine-readable output
*
* @param {Map<string, number>} globalProcessMap - Global map tracking all active processes
* @returns {Map<string, any>} Map of tool names to tool configurations
*
* @example
* ```typescript
* const globalProcesses = new Map();
* const tools = createFlutterTools(globalProcesses);
*
* // Use flutter_doctor
* const doctor = tools.get('flutter_doctor');
* const result = await doctor.handler({});
* console.log(result.data.machineOutput);
*
* // Start development session
* const run = tools.get('flutter_run');
* const session = await run.handler({
* cwd: '/path/to/project',
* deviceId: 'emulator-5554'
* });
* console.log(session.data.sessionId);
* ```
*
* @throws {Error} If Flutter SDK is not installed or not in PATH
* @throws {Error} If validation schemas fail for any tool parameters
*
* @see {@link https://docs.flutter.dev/reference/flutter-cli|Flutter CLI Reference}
*/
export function createFlutterTools(globalProcessMap: Map<string, number>): Map<string, any> {
// Initialize local sessions tracking if not provided
runningFlutterSessions = new Map();
const tools = new Map<string, any>();
// Flutter Doctor - System diagnostics
tools.set('flutter_doctor', {
name: 'flutter_doctor',
description: 'Run Flutter doctor to check development environment setup',
inputSchema: {
type: 'object',
properties: {},
required: []
},
handler: async () => {
const result = await processExecutor.execute('flutter', ['doctor', '--machine']);
if (result.exitCode !== 0) {
// Try fallback without --machine flag
const fallbackResult = await processExecutor.execute('flutter', ['doctor', '-v']);
return {
success: true,
data: {
status: 'completed_with_issues',
machineOutput: null,
humanOutput: fallbackResult.stdout,
issues: fallbackResult.stderr,
rawExitCode: result.exitCode,
},
};
}
// Parse machine output if available
let parsedOutput = null;
try {
parsedOutput = JSON.parse(result.stdout);
} catch {
// If JSON parsing fails, provide raw output
parsedOutput = null;
}
return {
success: true,
data: {
status: 'completed',
machineOutput: parsedOutput,
rawOutput: result.stdout,
issues: result.stderr,
exitCode: result.exitCode,
},
};
}
});
// Flutter Version - Get version information
tools.set('flutter_version', {
name: 'flutter_version',
description: 'Get Flutter SDK version information',
inputSchema: {
type: 'object',
properties: {},
required: []
},
handler: async () => {
const result = await processExecutor.execute('flutter', ['--version', '--machine']);
if (result.exitCode !== 0) {
// Try fallback without --machine flag
const fallbackResult = await processExecutor.execute('flutter', ['--version']);
return {
success: true,
data: {
machineOutput: null,
humanOutput: fallbackResult.stdout,
rawExitCode: result.exitCode,
},
};
}
// Parse machine output if available
let parsedOutput = null;
try {
parsedOutput = JSON.parse(result.stdout);
} catch {
// Fallback to human readable
const humanResult = await processExecutor.execute('flutter', ['--version']);
return {
success: true,
data: {
machineOutput: null,
humanOutput: humanResult.stdout,
rawExitCode: result.exitCode,
},
};
}
return {
success: true,
data: {
machineOutput: parsedOutput,
rawOutput: result.stdout,
exitCode: result.exitCode,
},
};
}
});
// Flutter Devices - List connected devices
tools.set('flutter_list_devices', {
name: 'flutter_list_devices',
description: 'List connected devices and emulators available for Flutter development',
inputSchema: {
type: 'object',
properties: {},
required: []
},
handler: async () => {
const result = await processExecutor.execute('flutter', ['devices', '--machine']);
if (result.exitCode !== 0) {
throw new Error(`Failed to list Flutter devices: ${result.stderr}`);
}
// Parse JSON output
let devices = [];
try {
devices = JSON.parse(result.stdout);
} catch (parseError) {
throw new Error(`Failed to parse devices output: ${parseError}`);
}
// Enhance device info with running session status
const enhancedDevices = devices.map((device: any) => ({
...device,
hasRunningSession: Array.from(runningFlutterSessions.values()).some(
session => session.deviceId === device.id
),
}));
return {
success: true,
data: {
devices: enhancedDevices,
totalCount: enhancedDevices.length,
connectedCount: enhancedDevices.filter((d: any) => d.isDevice).length,
simulatorCount: enhancedDevices.filter((d: any) => !d.isDevice).length,
runningSessionsCount: runningFlutterSessions.size,
},
};
}
});
// Flutter Emulators - List available emulators
tools.set('flutter_list_emulators', {
name: 'flutter_list_emulators',
description: 'List available emulators for Flutter development',
inputSchema: {
type: 'object',
properties: {},
required: []
},
handler: async () => {
const result = await processExecutor.execute('flutter', ['emulators']);
if (result.exitCode !== 0) {
throw new Error(`Failed to list Flutter emulators: ${result.stderr}`);
}
// Parse text output (emulators command doesn't support --machine flag)
const emulators = result.stdout
.split('\n')
.filter((line: string) => line.trim() && !line.includes('No emulators available'))
.map((line: string, index: number) => ({
id: `emulator_${index}`,
name: line.trim(),
available: true
}));
return {
success: true,
data: {
emulators,
totalCount: emulators.length,
},
};
}
});
// Flutter Emulators - Launch emulator
tools.set('flutter_launch_emulator', {
name: 'flutter_launch_emulator',
description: 'Launch a Flutter emulator',
inputSchema: {
type: 'object',
properties: {
emulatorId: { type: 'string', minLength: 1, description: 'Emulator ID to launch' }
},
required: ['emulatorId']
},
handler: async (args: any) => {
const validation = FlutterEmulatorLaunchSchema.safeParse(args);
if (!validation.success) {
throw new Error(`Invalid request: ${validation.error.message}`);
}
const { emulatorId } = validation.data;
// Validate emulator ID format (alphanumeric, underscores, dots, dashes)
if (!/^[a-zA-Z0-9._-]+$/.test(emulatorId)) {
throw new Error(`Invalid emulator ID format. Emulator ID can only contain alphanumeric characters, dots, underscores, and dashes: ${emulatorId}`);
}
const result = await processExecutor.execute(
'flutter',
['emulators', '--launch', emulatorId],
{ timeout: 180000 } // 3 minutes timeout for emulator launch
);
if (result.exitCode !== 0) {
throw new Error(`Failed to launch emulator: ${result.stderr || result.stdout}`);
}
return {
success: true,
data: {
emulatorId,
status: 'launched',
output: result.stdout,
},
};
}
});
// Flutter Run - Start development session
tools.set('flutter_run', {
name: 'flutter_run',
description: 'Start a Flutter development session (hot reload enabled)',
inputSchema: {
type: 'object',
properties: {
cwd: { type: 'string', minLength: 1, description: 'Working directory (Flutter project root)' },
deviceId: { type: 'string', description: 'Target device ID' },
target: { type: 'string', description: 'Target dart file (e.g., lib/main.dart)' },
flavor: { type: 'string', description: 'Build flavor' },
debugPort: { type: 'number', minimum: 1024, maximum: 65535, description: 'Debug port number' }
},
required: ['cwd']
},
handler: async (args: any) => {
const validation = FlutterRunSchema.safeParse(args);
if (!validation.success) {
throw new Error(`Invalid request: ${validation.error.message}`);
}
const { cwd, deviceId, target, flavor, debugPort } = validation.data;
// Validate that it's a Flutter project
await validateFlutterProject(cwd);
// Check if there's already a running session for this project
const existingSession = Array.from(runningFlutterSessions.entries()).find(
([_, session]) => session.projectPath === cwd
);
if (existingSession) {
return {
success: true,
data: {
sessionId: existingSession[0],
pid: existingSession[1].pid,
status: 'already_running',
projectPath: cwd,
deviceId: existingSession[1].deviceId,
message: 'Flutter run session is already active for this project',
},
};
}
const flutter_args = ['run'];
if (deviceId) {
flutter_args.push('-d', deviceId);
}
if (target) {
// Validate target path (must be .dart file)
if (!target.endsWith('.dart')) {
throw new Error(`Target must be a .dart file. Invalid target: ${target}`);
}
flutter_args.push('--target', target);
}
if (flavor) {
flutter_args.push('--flavor', flavor);
}
if (debugPort) {
flutter_args.push('--debug-port', debugPort.toString());
}
// Start flutter run in background
const flutterProcess = spawn('flutter', flutter_args, {
cwd,
detached: true,
stdio: ['ignore', 'pipe', 'pipe'],
});
const sessionId = `flutter_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
// Track the process
runningFlutterSessions.set(sessionId, {
pid: flutterProcess.pid!,
deviceId: deviceId || 'default',
projectPath: cwd,
});
// Handle process exit
flutterProcess.on('exit', () => {
runningFlutterSessions.delete(sessionId);
});
// Unref to allow the parent process to exit
flutterProcess.unref();
return {
success: true,
data: {
sessionId,
pid: flutterProcess.pid,
status: 'started',
projectPath: cwd,
deviceId: deviceId || 'default',
target: target || 'lib/main.dart',
flavor,
debugPort,
},
};
}
});
// Flutter Run - Stop development session
tools.set('flutter_stop_session', {
name: 'flutter_stop_session',
description: 'Stop a running Flutter development session',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string', minLength: 1, description: 'Flutter session ID to stop' }
},
required: ['sessionId']
},
handler: async (args: any) => {
const validation = FlutterStopSessionSchema.safeParse(args);
if (!validation.success) {
throw new Error(`Invalid request: ${validation.error.message}`);
}
const { sessionId } = validation.data;
const session = runningFlutterSessions.get(sessionId);
if (!session) {
throw new Error(`Flutter run session not found. No active session found with ID: ${sessionId}`);
}
try {
// Kill the process
process.kill(session.pid, 'SIGTERM');
// Remove from tracking
runningFlutterSessions.delete(sessionId);
return {
success: true,
data: {
sessionId,
pid: session.pid,
status: 'stopped',
projectPath: session.projectPath,
},
};
} catch (killError) {
// Process might already be dead
runningFlutterSessions.delete(sessionId);
return {
success: true,
data: {
sessionId,
pid: session.pid,
status: 'already_stopped',
projectPath: session.projectPath,
message: 'Process was already terminated',
},
};
}
}
});
// Flutter Run Sessions - List active sessions
tools.set('flutter_list_sessions', {
name: 'flutter_list_sessions',
description: 'List active Flutter development sessions',
inputSchema: {
type: 'object',
properties: {},
required: []
},
handler: async () => {
const sessions = Array.from(runningFlutterSessions.entries()).map(([sessionId, session]) => ({
sessionId,
...session,
}));
return {
success: true,
data: {
sessions,
totalCount: sessions.length,
},
};
}
});
// Flutter Build - Build app for target platform
tools.set('flutter_build', {
name: 'flutter_build',
description: 'Build Flutter app for specific target platform',
inputSchema: {
type: 'object',
properties: {
cwd: { type: 'string', minLength: 1, description: 'Working directory (Flutter project root)' },
target: {
type: 'string',
enum: ['apk', 'appbundle', 'ipa', 'ios', 'android', 'web', 'windows', 'macos', 'linux'],
description: 'Build target platform'
},
buildMode: {
type: 'string',
enum: ['debug', 'profile', 'release'],
description: 'Build mode'
},
flavor: { type: 'string', description: 'Build flavor' }
},
required: ['cwd', 'target']
},
handler: async (args: any) => {
const validation = FlutterBuildSchema.safeParse(args);
if (!validation.success) {
throw new Error(`Invalid request: ${validation.error.message}`);
}
const { cwd, target, buildMode = 'debug', flavor } = validation.data;
// Validate that it's a Flutter project
await validateFlutterProject(cwd);
const flutter_args = ['build', target];
if (buildMode !== 'debug') {
flutter_args.push(`--${buildMode}`);
}
if (flavor) {
flutter_args.push('--flavor', flavor);
}
const result = await processExecutor.execute('flutter', flutter_args, {
cwd,
timeout: 1800000, // 30 minutes timeout for builds
});
if (result.exitCode !== 0) {
throw new Error(`Flutter build failed: ${result.stderr || result.stdout}`);
}
return {
success: true,
data: {
target,
buildMode,
flavor,
projectPath: cwd,
output: result.stdout,
duration: result.duration,
exitCode: result.exitCode,
},
};
}
});
// Flutter Test - Run tests
tools.set('flutter_test', {
name: 'flutter_test',
description: 'Run Flutter tests with optional coverage',
inputSchema: {
type: 'object',
properties: {
cwd: { type: 'string', minLength: 1, description: 'Working directory (Flutter project root)' },
testFile: { type: 'string', description: 'Specific test file to run (optional)' },
coverage: { type: 'boolean', description: 'Enable test coverage' }
},
required: ['cwd']
},
handler: async (args: any) => {
const validation = FlutterTestSchema.safeParse(args);
if (!validation.success) {
throw new Error(`Invalid request: ${validation.error.message}`);
}
const { cwd, testFile, coverage = false } = validation.data;
// Validate that it's a Flutter project
await validateFlutterProject(cwd);
const flutter_args = ['test'];
if (testFile) {
// Validate test file path (must be .dart file in test directory)
if (!testFile.endsWith('.dart')) {
throw new Error(`Test file must be a .dart file. Invalid test file: ${testFile}`);
}
// Check if test file exists
const testFilePath = path.isAbsolute(testFile) ? testFile : path.join(cwd, testFile);
try {
await fs.access(testFilePath);
} catch {
throw new Error(`Test file not found. File does not exist: ${testFilePath}`);
}
flutter_args.push(testFile);
}
if (coverage) {
flutter_args.push('--coverage');
}
const result = await processExecutor.execute('flutter', flutter_args, {
cwd,
timeout: 600000, // 10 minutes timeout for tests
});
return {
success: true,
data: {
testFile: testFile || 'all tests',
coverage,
projectPath: cwd,
exitCode: result.exitCode,
output: result.stdout,
errors: result.stderr,
duration: result.duration,
passed: result.exitCode === 0,
},
};
}
});
// Flutter Clean - Clean build cache
tools.set('flutter_clean', {
name: 'flutter_clean',
description: 'Clean Flutter build cache and generated files',
inputSchema: {
type: 'object',
properties: {
cwd: { type: 'string', minLength: 1, description: 'Working directory (Flutter project root)' }
},
required: ['cwd']
},
handler: async (args: any) => {
const validation = FlutterCleanSchema.safeParse(args);
if (!validation.success) {
throw new Error(`Invalid request: ${validation.error.message}`);
}
const { cwd } = validation.data;
// Validate that it's a Flutter project
await validateFlutterProject(cwd);
const result = await processExecutor.execute('flutter', ['clean'], {
cwd,
timeout: 120000, // 2 minutes timeout for clean
});
return {
success: true,
data: {
projectPath: cwd,
exitCode: result.exitCode,
output: result.stdout,
duration: result.duration,
},
};
}
});
// Flutter Pub Get - Install dependencies
tools.set('flutter_pub_get', {
name: 'flutter_pub_get',
description: 'Install Flutter project dependencies (pub get)',
inputSchema: {
type: 'object',
properties: {
cwd: { type: 'string', minLength: 1, description: 'Working directory (Flutter project root)' }
},
required: ['cwd']
},
handler: async (args: any) => {
const validation = FlutterPubGetSchema.safeParse(args);
if (!validation.success) {
throw new Error(`Invalid request: ${validation.error.message}`);
}
const { cwd } = validation.data;
// Validate that it's a Flutter project
await validateFlutterProject(cwd);
const result = await processExecutor.execute('flutter', ['pub', 'get'], {
cwd,
timeout: 300000, // 5 minutes timeout for pub get
});
return {
success: true,
data: {
projectPath: cwd,
exitCode: result.exitCode,
output: result.stdout,
errors: result.stderr,
duration: result.duration,
success: result.exitCode === 0,
},
};
}
});
// Flutter Screenshot - Take screenshot from connected device
tools.set('flutter_screenshot', {
name: 'flutter_screenshot',
description: 'Take a screenshot from a connected Flutter device',
inputSchema: {
type: 'object',
properties: {
deviceId: { type: 'string', description: 'Target device ID (optional)' },
outputPath: { type: 'string', description: 'Output PNG file path (optional)' }
}
},
handler: async (args: any) => {
const validation = FlutterScreenshotSchema.safeParse(args);
if (!validation.success) {
throw new Error(`Invalid request: ${validation.error.message}`);
}
const { deviceId, outputPath } = validation.data;
const flutter_args = ['screenshot'];
if (deviceId) {
// Validate device ID format
if (!/^[a-zA-Z0-9._:-]+$/.test(deviceId)) {
throw new Error(`Invalid device ID format. Device ID contains invalid characters: ${deviceId}`);
}
flutter_args.push('-d', deviceId);
}
if (outputPath) {
// Validate output path (must end with .png)
if (!outputPath.endsWith('.png')) {
throw new Error(`Screenshot output must be a .png file. Invalid path: ${outputPath}`);
}
flutter_args.push('-o', outputPath);
}
const result = await processExecutor.execute('flutter', flutter_args, {
timeout: 60000, // 1 minute timeout for screenshot
});
if (result.exitCode !== 0) {
throw new Error(`Screenshot capture failed: ${result.stderr || result.stdout}`);
}
return {
success: true,
data: {
deviceId: deviceId || 'default',
outputPath: outputPath || 'flutter_screenshot.png',
output: result.stdout,
duration: result.duration,
},
};
}
});
return tools;
}