watch
Monitor project files for changes and automatically revalidate contracts to detect schema mismatches between data producers and consumers.
Instructions
Watch project files for changes and auto-revalidate contracts. Actions: start (begin watching), stop (end watching), status (check state), poll (get pending events).
Input Schema
TableJSON Schema
| Name | Required | Description | Default |
|---|---|---|---|
| projectDir | Yes | Root directory with .trace-mcp config | |
| action | No | Watch action (default: start) |
Implementation Reference
- src/index.ts:602-691 (handler)Main MCP tool handler for 'watch'. Parses input, loads TraceProject, retrieves or creates TraceWatcher, and executes actions: start (setup watchers and initial validation), stop, status, poll. Returns JSON status and events.case 'watch': { const input = WatchInput.parse(args); const action = input.action || 'start'; log(`Watch action: ${action} for ${input.projectDir}`); const project = loadProject(input.projectDir); if (!project.exists()) { throw new Error(`No trace project found at ${input.projectDir}. Run init_project first.`); } const watcher = getWatcher(project); switch (action) { case 'start': { // Collect events during startup const events: WatchEvent[] = []; const eventHandler = (event: WatchEvent) => events.push(event); watcher.on('watch-event', eventHandler); await watcher.start(); // Wait a moment for initial validation await new Promise(resolve => setTimeout(resolve, 100)); watcher.off('watch-event', eventHandler); return { content: [{ type: 'text', text: JSON.stringify({ success: true, action: 'started', projectDir: input.projectDir, status: watcher.getStatus(), events, }, null, 2), }], }; } case 'stop': { await stopWatcher(project); return { content: [{ type: 'text', text: JSON.stringify({ success: true, action: 'stopped', projectDir: input.projectDir, }, null, 2), }], }; } case 'status': { return { content: [{ type: 'text', text: JSON.stringify({ success: true, action: 'status', projectDir: input.projectDir, status: watcher.getStatus(), activeWatchers: listActiveWatchers(), }, null, 2), }], }; } case 'poll': { // For polling, collect recent events const status = watcher.getStatus(); return { content: [{ type: 'text', text: JSON.stringify({ success: true, action: 'poll', projectDir: input.projectDir, status, lastResult: status.lastResult, }, null, 2), }], }; } default: throw new Error(`Unknown watch action: ${action}`); } }
- src/index.ts:252-263 (registration)MCP tool registration for 'watch' including name, description, and inputSchema advertised to clients.{ name: 'watch', description: 'Watch project files for changes and auto-revalidate contracts. Actions: start (begin watching), stop (end watching), status (check state), poll (get pending events).', inputSchema: { type: 'object', properties: { projectDir: { type: 'string', description: 'Root directory with .trace-mcp config' }, action: { type: 'string', enum: ['start', 'stop', 'status', 'poll'], description: 'Watch action (default: start)' }, }, required: ['projectDir'], }, },
- src/index.ts:104-107 (schema)Zod schema used for input validation in the 'watch' tool handler.const WatchInput = z.object({ projectDir: z.string().describe('Root directory with .trace-mcp config'), action: z.enum(['start', 'stop', 'status', 'poll']).default('start').describe('Watch action to perform'), });
- src/watch/watcher.ts:50-291 (helper)Core TraceWatcher class implementing file watching with chokidar, debounced revalidation (extract schemas, trace usage, compare), event emission, and status tracking. Used by 'watch' tool handler.export class TraceWatcher extends EventEmitter { private project: TraceProject; private cache: SchemaCache; private producerWatcher: FSWatcher | null = null; private consumerWatcher: FSWatcher | null = null; private debounceTimer: NodeJS.Timeout | null = null; private pendingChanges: Map<string, FileChangeData> = new Map(); private isRunning = false; private lastResult: TraceResult | null = null; constructor(project: TraceProject) { super(); this.project = project; this.cache = new SchemaCache(project); } // -------------------------------------------------------------------------- // Lifecycle // -------------------------------------------------------------------------- /** * Start watching files */ async start(): Promise<void> { if (this.isRunning) { throw new Error('Watcher is already running'); } const config = this.project.config; this.isRunning = true; // Build glob patterns for producer const producerPatterns = config.producer.include.map( p => join(this.project.producerPath, p) ); const producerIgnore = config.producer.exclude.map( p => join(this.project.producerPath, p) ); // Build glob patterns for consumer const consumerPatterns = config.consumer.include.map( p => join(this.project.consumerPath, p) ); const consumerIgnore = config.consumer.exclude.map( p => join(this.project.consumerPath, p) ); // Start producer watcher this.producerWatcher = chokidar.watch(producerPatterns, { ignored: producerIgnore, persistent: true, ignoreInitial: true, }); this.producerWatcher.on('add', (path: string) => this.onFileChange(path, 'producer', 'add')); this.producerWatcher.on('change', (path: string) => this.onFileChange(path, 'producer', 'change')); this.producerWatcher.on('unlink', (path: string) => this.onFileChange(path, 'producer', 'unlink')); this.producerWatcher.on('error', (err: unknown) => this.onError(err instanceof Error ? err : new Error(String(err)))); // Start consumer watcher this.consumerWatcher = chokidar.watch(consumerPatterns, { ignored: consumerIgnore, persistent: true, ignoreInitial: true, }); this.consumerWatcher.on('add', (path: string) => this.onFileChange(path, 'consumer', 'add')); this.consumerWatcher.on('change', (path: string) => this.onFileChange(path, 'consumer', 'change')); this.consumerWatcher.on('unlink', (path: string) => this.onFileChange(path, 'consumer', 'unlink')); this.consumerWatcher.on('error', (err: unknown) => this.onError(err instanceof Error ? err : new Error(String(err)))); // Initial validation await this.runValidation('initial'); this.emitEvent('ready', { producerPath: this.project.producerPath, consumerPath: this.project.consumerPath, }); } /** * Stop watching files */ async stop(): Promise<void> { if (!this.isRunning) return; if (this.debounceTimer) { clearTimeout(this.debounceTimer); this.debounceTimer = null; } if (this.producerWatcher) { await this.producerWatcher.close(); this.producerWatcher = null; } if (this.consumerWatcher) { await this.consumerWatcher.close(); this.consumerWatcher = null; } this.isRunning = false; this.emitEvent('stopped', {}); } // -------------------------------------------------------------------------- // Event Handlers // -------------------------------------------------------------------------- private onFileChange( filePath: string, side: 'producer' | 'consumer', changeType: 'add' | 'change' | 'unlink' ): void { const relPath = relative(this.project.rootDir, filePath); this.emitEvent('file_changed', { file: relPath, side, changeType, } as FileChangeData); // Accumulate changes this.pendingChanges.set(filePath, { file: relPath, side, changeType }); // Invalidate cache for this file this.cache.invalidateFiles([filePath]); // Debounce revalidation if (this.debounceTimer) { clearTimeout(this.debounceTimer); } const debounceMs = this.project.config.options.debounceMs ?? 300; this.debounceTimer = setTimeout(() => { this.processPendingChanges(); }, debounceMs); } private onError(error: Error): void { this.emitEvent('error', { message: error.message, stack: error.stack, }); } // -------------------------------------------------------------------------- // Validation // -------------------------------------------------------------------------- private async processPendingChanges(): Promise<void> { const changes = Array.from(this.pendingChanges.values()); this.pendingChanges.clear(); if (changes.length === 0) return; const triggeredBy = changes.map(c => c.file).join(', '); await this.runValidation(triggeredBy); } private async runValidation(triggeredBy: string): Promise<void> { this.emitEvent('revalidating', { triggeredBy }); try { // Extract and trace using configured languages const producers = await extractProducerSchemas({ rootDir: this.project.producerPath, language: this.project.config.producer.language, include: this.project.config.producer.include, exclude: this.project.config.producer.exclude, }); const consumers = await traceConsumerUsage({ rootDir: this.project.consumerPath, language: this.project.config.consumer.language, include: this.project.config.consumer.include, exclude: this.project.config.consumer.exclude, }); // Compare const result = compareSchemas(producers, consumers, { strict: this.project.config.options.strict, direction: this.project.config.options.direction, }); this.lastResult = result; this.emitEvent('result', { result, triggeredBy, cached: false, } as ResultData); } catch (error) { const message = error instanceof Error ? error.message : String(error); this.emitEvent('error', { message, triggeredBy }); } } // -------------------------------------------------------------------------- // Utility // -------------------------------------------------------------------------- private emitEvent(type: WatchEventType, data: unknown): void { const event: WatchEvent = { type, timestamp: new Date().toISOString(), data, }; this.emit('watch-event', event); } /** * Get the last validation result */ getLastResult(): TraceResult | null { return this.lastResult; } /** * Get current watcher status */ getStatus(): { running: boolean; lastResult: TraceResult | null; pendingChanges: number; } { return { running: this.isRunning, lastResult: this.lastResult, pendingChanges: this.pendingChanges.size, }; } /** * Force a revalidation */ async forceRevalidation(): Promise<TraceResult> { await this.runValidation('manual'); return this.lastResult!; } }