Skip to main content
Glama

change_watcher

Monitor code changes to automatically detect documentation drift in real-time, ensuring documentation stays synchronized with project updates.

Instructions

Watch code changes and trigger documentation drift detection in near real-time.

Input Schema

TableJSON Schema
NameRequiredDescriptionDefault
actionNoAction to performstatus
projectPathYesProject root path
docsPathYesDocumentation path
watchPathsNoPaths to watch (defaults to src/)
excludePatternsNoGlob patterns to exclude
debounceMsNoDebounce window for drift detection
triggerOnCommitNoRespond to git commit events
triggerOnPRNoRespond to PR/merge events
webhookEndpointNoWebhook endpoint path (e.g., /hooks/documcp/change-watcher)
webhookSecretNoShared secret for webhook signature validation
portNoPort for webhook server (default 8787)
snapshotDirNoSnapshot directory override
reasonNoReason for manual trigger
filesNoChanged files (for manual trigger)

Implementation Reference

  • The main handler function `handleChangeWatcher` that parses arguments, handles different actions (start, status, stop, trigger, install_hook), manages singleton watcher instance, and includes the `startWatcher` helper function.
    export async function handleChangeWatcher(
      args: unknown,
      context?: any,
    ): Promise<MCPContentWrapper> {
      const parsed = changeWatcherSchema.parse(args);
    
      switch (parsed.action) {
        case "start":
          return await startWatcher(parsed, context);
        case "status":
          return makeResponse(watcher ? watcher.getStatus() : { running: false });
        case "stop":
          if (watcher) {
            await watcher.stop();
            watcher = null;
          }
          return makeResponse({ running: false });
        case "trigger":
          if (!watcher) {
            await startWatcher(parsed, context);
          }
          if (!watcher) {
            throw new Error("Change watcher not available");
          }
          return makeResponse(
            await watcher.triggerManual(parsed.reason, parsed.files),
          );
        case "install_hook":
          if (!watcher) {
            await startWatcher(parsed, context);
          }
          if (!watcher) {
            throw new Error("Change watcher not available");
          }
          return makeResponse({
            hook: await watcher.installGitHook("post-commit"),
          });
      }
    }
    
    async function startWatcher(
      options: ChangeWatcherArgs,
      context?: any,
    ): Promise<MCPContentWrapper> {
      if (!watcher) {
        watcher = new ChangeWatcher(
          {
            projectPath: options.projectPath,
            docsPath: options.docsPath,
            watchPaths: options.watchPaths,
            excludePatterns: options.excludePatterns,
            debounceMs: options.debounceMs,
            triggerOnCommit: options.triggerOnCommit,
            triggerOnPR: options.triggerOnPR,
            webhookEndpoint: options.webhookEndpoint,
            webhookSecret: options.webhookSecret,
            port: options.port,
            snapshotDir: options.snapshotDir,
          },
          {
            logger: {
              info: context?.info,
              warn: context?.warn,
              error: context?.error,
            },
          },
        );
        await watcher.start();
      }
    
      return makeResponse(watcher.getStatus());
    }
  • Zod schema defining input parameters for the change_watcher tool, including actions, paths, debounce settings, webhook config, etc.
    export const changeWatcherSchema = z.object({
      action: z
        .enum(["start", "status", "stop", "trigger", "install_hook"])
        .default("status")
        .describe("Action to perform"),
      projectPath: z.string().describe("Project root path"),
      docsPath: z.string().describe("Documentation path"),
      watchPaths: z
        .array(z.string())
        .optional()
        .describe("Paths to watch (defaults to src/)"),
      excludePatterns: z
        .array(z.string())
        .optional()
        .describe("Glob patterns to exclude"),
      debounceMs: z
        .number()
        .min(50)
        .max(600000)
        .default(500)
        .describe("Debounce window for drift detection"),
      triggerOnCommit: z
        .boolean()
        .default(true)
        .describe("Respond to git commit events"),
      triggerOnPR: z.boolean().default(true).describe("Respond to PR/merge events"),
      webhookEndpoint: z
        .string()
        .optional()
        .describe("Webhook endpoint path (e.g., /hooks/documcp/change-watcher)"),
      webhookSecret: z
        .string()
        .optional()
        .describe("Shared secret for webhook signature validation"),
      port: z
        .number()
        .min(1)
        .max(65535)
        .optional()
        .describe("Port for webhook server (default 8787)"),
      snapshotDir: z.string().optional().describe("Snapshot directory override"),
      reason: z.string().optional().describe("Reason for manual trigger"),
      files: z
        .array(z.string())
        .optional()
        .describe("Changed files (for manual trigger)"),
    });
  • Tool registration exporting the MCP Tool object with name 'change_watcher', description, and input schema derived from zod schema.
    export const changeWatcherTool: Tool = {
      name: "change_watcher",
      description:
        "Watch code changes and trigger documentation drift detection in near real-time.",
      inputSchema: zodToJsonSchema(changeWatcherSchema) as any,
    };
  • Core ChangeWatcher class that implements file system watching with chokidar, webhook server for git events, git hook installation, debounced drift detection triggering using DriftDetector, and status reporting.
    export class ChangeWatcher {
      private watcher: FSWatcher | null = null;
      private server: http.Server | null = null;
      private debounceTimer: NodeJS.Timeout | null = null;
      private readonly queuedEvents: ChangeEvent[] = [];
      private readonly options: NormalizedChangeWatcherOptions;
      private readonly deps: ChangeWatcherDeps;
      private detector: DriftDetectorLike | null = null;
      private usageCollector: UsageMetadataCollector;
      private latestSnapshot: DriftSnapshot | null = null;
      private isRunningDetection = false;
      private stopped = false;
    
      constructor(options: ChangeWatcherOptions, deps: ChangeWatcherDeps = {}) {
        const triggerOnCommit = options.triggerOnCommit ?? true;
        const triggerOnPR = options.triggerOnPR ?? true;
        const normalized: NormalizedChangeWatcherOptions = {
          ...options,
          triggerOnCommit,
          triggerOnPR,
          debounceMs: Math.max(50, options.debounceMs ?? 500),
          excludePatterns: options.excludePatterns ?? [
            "**/node_modules/**",
            "**/.git/**",
            "**/.documcp/**",
          ],
          watchPaths:
            options.watchPaths && options.watchPaths.length > 0
              ? options.watchPaths
              : [path.join(options.projectPath, "src")],
        };
        this.options = normalized;
        this.deps = deps;
        this.usageCollector = new UsageMetadataCollector();
      }
    
      async start(): Promise<void> {
        this.stopped = false;
        await this.ensureDetector();
        await this.ensureBaseline();
        this.startFsWatcher();
        await this.startWebhookServer();
        this.logInfo(
          `Change watcher started (debounce ${
            this.options.debounceMs
          }ms, paths: ${this.options.watchPaths.join(", ")})`,
        );
      }
    
      async stop(): Promise<void> {
        this.stopped = true;
        if (this.watcher) {
          await this.watcher.close();
          this.watcher = null;
        }
        if (this.server) {
          await new Promise<void>((resolve) => this.server?.close(() => resolve()));
          this.server = null;
        }
        if (this.debounceTimer) {
          clearTimeout(this.debounceTimer);
          this.debounceTimer = null;
        }
      }
    
      getStatus(): {
        running: boolean;
        webhook?: { port: number; endpoint: string };
        watchPaths: string[];
        debounceMs: number;
        pendingEvents: number;
      } {
        return {
          running: !this.stopped,
          webhook: this.options.webhookEndpoint
            ? {
                port: this.options.port ?? 8787,
                endpoint: this.options.webhookEndpoint,
              }
            : undefined,
          watchPaths: this.options.watchPaths,
          debounceMs: this.options.debounceMs,
          pendingEvents: this.queuedEvents.length,
        };
      }
    
      async installGitHook(hook: "post-commit" = "post-commit"): Promise<string> {
        const gitDir = path.join(this.options.projectPath, ".git");
        const hookPath = path.join(gitDir, "hooks", hook);
        const endpoint =
          this.options.webhookEndpoint || "/hooks/documcp/change-watcher";
        const port = this.options.port ?? 8787;
        const script = `#!/bin/sh
    # Auto-generated by documcp change watcher
    if command -v curl >/dev/null 2>&1; then
      curl -s -X POST http://localhost:${port}${endpoint} \\
        -H "X-DocuMCP-Event=${hook}" \\
        -H "Content-Type: application/json" \\
        -d '{"event":"${hook}"}' >/dev/null 2>&1 || true
    fi
    `;
        await fs.mkdir(path.dirname(hookPath), { recursive: true });
        await fs.writeFile(hookPath, script, { mode: 0o755 });
        return hookPath;
      }
    
      async enqueueChange(event: ChangeEvent): Promise<void> {
        if (
          event.type === "post-commit" &&
          this.options.triggerOnCommit === false
        ) {
          return;
        }
    
        if (
          (event.type === "pull_request" || event.type === "branch_merge") &&
          this.options.triggerOnPR === false
        ) {
          return;
        }
    
        this.queuedEvents.push(event);
        if (this.debounceTimer) {
          clearTimeout(this.debounceTimer);
        }
        this.debounceTimer = setTimeout(() => {
          void this.runDetection();
        }, this.options.debounceMs);
      }
    
      async triggerManual(
        reason = "manual",
        files?: string[],
      ): Promise<ChangeWatcherResult> {
        await this.enqueueChange({
          type: "manual",
          files,
          metadata: { reason },
          source: "manual",
        });
        const result = await this.runDetection();
        if (result) {
          return result;
        }
        return this.buildResult([]);
      }
    
      private async ensureDetector(): Promise<void> {
        if (!this.detector) {
          const factory =
            this.deps.createDetector ??
            ((projectPath: string, snapshotDir?: string) =>
              new DriftDetector(projectPath, snapshotDir));
          this.detector = factory(
            this.options.projectPath,
            this.options.snapshotDir,
          );
          await this.detector.initialize();
        }
      }
    
      private async ensureBaseline(): Promise<void> {
        if (!this.detector) return;
        const latest = await this.detector.loadLatestSnapshot();
        if (latest) {
          this.latestSnapshot = latest;
          return;
        }
        this.latestSnapshot = await this.detector.createSnapshot(
          this.options.projectPath,
          this.options.docsPath,
        );
      }
    
      private startFsWatcher(): void {
        if (this.options.watchPaths.length === 0) return;
        const normalizedWatchPaths = this.options.watchPaths.map((p) =>
          path.isAbsolute(p) ? p : path.join(this.options.projectPath, p),
        );
        this.watcher = chokidar.watch(normalizedWatchPaths, {
          ignored: this.options.excludePatterns,
          persistent: true,
          ignoreInitial: true,
        });
    
        const onFsEvent = (filePath: string) => {
          void this.enqueueChange({
            type: "filesystem",
            files: [filePath],
            source: "fs",
          });
        };
    
        this.watcher.on("add", onFsEvent);
        this.watcher.on("change", onFsEvent);
        this.watcher.on("unlink", onFsEvent);
      }
    
      private async startWebhookServer(): Promise<void> {
        if (!this.options.webhookEndpoint) return;
        const endpoint = this.options.webhookEndpoint;
        const port = this.options.port ?? 8787;
    
        this.server = http.createServer(async (req, res) => {
          if (req.method !== "POST" || req.url !== endpoint) {
            res.statusCode = 404;
            res.end("Not found");
            return;
          }
    
          const body = await this.readRequestBody(req);
    
          if (!this.verifySignature(req, body)) {
            res.statusCode = 401;
            res.end("Invalid signature");
            return;
          }
    
          const eventHeader =
            (req.headers["x-github-event"] as string) ||
            (req.headers["x-gitlab-event"] as string) ||
            (req.headers["x-documcp-event"] as string) ||
            "webhook";
    
          const parsedBody = this.safeParseJson(body);
          const changeEvent = this.mapWebhookToChangeEvent(eventHeader, parsedBody);
          await this.enqueueChange(changeEvent);
    
          res.statusCode = 200;
          res.end("OK");
        });
    
        await new Promise<void>((resolve) => this.server?.listen(port, resolve));
        this.logInfo(`Webhook server listening on port ${port}${endpoint}`);
      }
    
      private verifySignature(req: http.IncomingMessage, body: string): boolean {
        if (!this.options.webhookSecret) return true;
        const githubSig = req.headers["x-hub-signature-256"] as string | undefined;
        if (githubSig) {
          const expected = `sha256=${crypto
            .createHmac("sha256", this.options.webhookSecret)
            .update(body)
            .digest("hex")}`;
          const expectedBuf = Buffer.from(expected);
          const receivedBuf = Buffer.from(githubSig);
          if (expectedBuf.length !== receivedBuf.length) {
            return false;
          }
          return crypto.timingSafeEqual(expectedBuf, receivedBuf);
        }
    
        const gitlabToken = req.headers["x-gitlab-token"] as string | undefined;
        if (gitlabToken) {
          return gitlabToken === this.options.webhookSecret;
        }
    
        return false;
      }
    
      private mapWebhookToChangeEvent(
        event: string,
        payload: Record<string, unknown>,
      ): ChangeEvent {
        if (event === "push" || event === "post-commit") {
          return {
            type: "post-commit",
            files: this.extractFilesFromPayload(payload),
            metadata: { event },
            source: "git",
          };
        }
    
        if (event === "pull_request") {
          return {
            type: "pull_request",
            files: this.extractFilesFromPayload(payload),
            metadata: { event },
            source: "git",
          };
        }
    
        if (event === "merge_request" || event === "merge") {
          return {
            type: "branch_merge",
            files: this.extractFilesFromPayload(payload),
            metadata: { event },
            source: "git",
          };
        }
    
        return {
          type: "manual",
          metadata: { event },
          source: "webhook",
        };
      }
    
      private extractFilesFromPayload(payload: Record<string, unknown>): string[] {
        const files: string[] = [];
        const commits = (payload?.commits as any[]) || [];
        for (const commit of commits) {
          files.push(
            ...(commit.added ?? []),
            ...(commit.modified ?? []),
            ...(commit.removed ?? []),
          );
        }
        return Array.from(new Set(files));
      }
    
      private async runDetection(): Promise<ChangeWatcherResult | null> {
        if (this.isRunningDetection || !this.detector) return null;
        if (this.queuedEvents.length === 0) return null;
    
        this.isRunningDetection = true;
        const events = [...this.queuedEvents];
        this.queuedEvents.length = 0;
    
        try {
          if (!this.latestSnapshot) {
            await this.ensureBaseline();
          }
          if (!this.latestSnapshot) {
            this.logWarn("No baseline snapshot available for drift detection.");
            return null;
          }
    
          const currentSnapshot = await this.detector.createSnapshot(
            this.options.projectPath,
            this.options.docsPath,
          );
          // Use async collection with call graph analysis when available
          // Falls back to sync collection if analyzer not initialized
          const usageMetadata = await this.usageCollector
            .collect(currentSnapshot)
            .catch(() => this.usageCollector.collectSync(currentSnapshot));
          const driftResults = await this.detector.getPrioritizedDriftResults(
            this.latestSnapshot,
            currentSnapshot,
            usageMetadata,
          );
          this.latestSnapshot = currentSnapshot;
    
          const result = this.buildResultFromDrift(
            driftResults,
            events,
            currentSnapshot,
          );
          this.logInfo(
            `Drift detection completed: ${result.changedSymbols.length} symbols changed, ${result.affectedDocs.length} doc(s) affected.`,
          );
          return result;
        } catch (error: any) {
          this.logError(`Change watcher detection failed: ${error.message}`);
        } finally {
          this.isRunningDetection = false;
        }
    
        return null;
      }
    
      private buildResultFromDrift(
        driftResults: PrioritizedDriftResult[],
        events: ChangeEvent[],
        snapshot: DriftSnapshot,
      ): ChangeWatcherResult {
        const changedSymbols: ChangeWatcherResult["changedSymbols"] = [];
        const affectedDocs = new Set<string>();
    
        for (const result of driftResults) {
          for (const drift of result.drifts) {
            for (const diff of drift.codeChanges) {
              changedSymbols.push({
                name: diff.name,
                category: diff.category,
                impact: drift.severity,
                filePath: result.filePath,
              });
            }
            drift.affectedDocs.forEach((doc) => affectedDocs.add(doc));
          }
          result.impactAnalysis.affectedDocFiles.forEach((doc) =>
            affectedDocs.add(doc),
          );
        }
    
        return {
          snapshotId: snapshot.timestamp,
          driftResults,
          changedSymbols,
          affectedDocs: Array.from(affectedDocs),
          events,
        };
      }
    
      private async buildResult(
        events: ChangeEvent[],
      ): Promise<ChangeWatcherResult> {
        if (!this.latestSnapshot) {
          throw new Error("No snapshot available");
        }
        return this.buildResultFromDrift([], events, this.latestSnapshot);
      }
    
      private async readRequestBody(req: http.IncomingMessage): Promise<string> {
        return await new Promise((resolve) => {
          let data = "";
          req.on("data", (chunk) => {
            data += chunk;
          });
          req.on("end", () => resolve(data));
        });
      }
    
      private safeParseJson(body: string): Record<string, unknown> {
        try {
          return JSON.parse(body);
        } catch {
          return {};
        }
      }
    
      private logInfo(message: string): void {
        this.deps.logger?.info?.(message);
      }
    
      private logWarn(message: string): void {
        this.deps.logger?.warn?.(message);
      }
    
      private logError(message: string): void {
        this.deps.logger?.error?.(message);
      }
    }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/tosin2013/documcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server