mcp-methods 0.3.40

Reusable utility methods for MCP servers — pure-Rust library
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
//! Filesystem-watcher subsystem for `--watch DIR` mode.
//!
//! Boots a debounced recursive watcher on the configured directory and
//! invokes a caller-supplied callback when files change. Downstream
//! binaries register callbacks to drive whatever rebuild they need —
//! kglite-mcp-server, for example, wires this to `code_tree::build()`
//! against the watched directory and atomic-swaps the active graph.
//!
//! mcp-methods's binary on its own does not own a rebuild target;
//! it logs change events at INFO level and forwards them to any
//! registered callback. When no callback is set the watcher still
//! runs, so the change events show up in stderr.
//!
//! ## Default skip patterns
//!
//! Events matching conventional noise paths ([`DEFAULT_SKIP_SUBSTRINGS`]
//! and [`DEFAULT_SKIP_EXTENSIONS`]) are dropped before the callback
//! runs — `.git/`, `target/`, `node_modules/`, `__pycache__/`, `*.pyc`,
//! editor swap files, etc. A wide sandbox under active development
//! generates hundreds of these per second; without the filter every
//! consumer either rebuilds wastefully or implements the same skip
//! list. With it, consumers see only events that could plausibly
//! matter.
//!
//! Bindings that need everything (test fixtures, future consumers
//! with a genuine reason to see every event) pass
//! [`WatchConfig::unfiltered`] to [`watch_with_config`].

#![allow(dead_code)]

use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;

use anyhow::{Context, Result};
use notify_debouncer_mini::notify::RecursiveMode;
use notify_debouncer_mini::{new_debouncer, DebounceEventResult, Debouncer};

/// Callback invoked on a debounced file-change event.
///
/// `paths` is the deduplicated set of paths reported as changed within
/// the debounce window, **after** the active [`WatchConfig`]'s skip
/// filter has run. The callback runs on a background thread; keep it
/// non-blocking or push work onto a channel.
pub type ChangeHandler = Arc<dyn Fn(&[PathBuf]) + Send + Sync>;

/// Default debounce window — short enough to feel responsive, long
/// enough to coalesce noisy editor saves and IDE temp-file dance.
pub const DEFAULT_DEBOUNCE: Duration = Duration::from_millis(500);

/// Default substrings to skip. A path containing any of these as a
/// substring is dropped before the callback runs.
///
/// Conventional build / VCS / cache directories that no graph builder,
/// search index, or rebuild target should care about. The substrings
/// are anchored with `/` on both sides where appropriate so they don't
/// false-match (e.g. `/.git/` matches `.../my-repo/.git/HEAD` but not
/// a file literally named `.gitignore`).
pub const DEFAULT_SKIP_SUBSTRINGS: &[&str] = &[
    "/.git/",         // git objects + index churn on any git operation
    "/target/",       // Cargo build artifacts (worst storm offender)
    "/node_modules/", // npm/yarn install storms + cache writes
    "/__pycache__/",  // CPython bytecode dirs
    "/.venv/",        // Python venv internals
    "/build/",        // generic build outputs across many tools
    "/dist/",         // generic build/distribution outputs
    "/.DS_Store",     // macOS Finder metadata churn
];

/// Default file extensions to skip (without the leading dot).
pub const DEFAULT_SKIP_EXTENSIONS: &[&str] = &[
    "pyc", "pyo", // CPython bytecode files
    "swp", "swo", // vim swap files
    "tmp", // atomic-save temp files
];

/// Configuration for a [`watch_with_config`] call. Controls which
/// events reach the callback.
#[derive(Clone, Debug)]
pub struct WatchConfig {
    /// Substrings to skip. A path containing any of these (anywhere)
    /// is dropped before the callback fires. Matching is
    /// case-sensitive and allocation-free.
    pub skip_substrings: Vec<String>,
    /// File extensions (without leading dot) to skip. Matching uses
    /// the path's last extension via [`Path::extension`] and is
    /// case-sensitive.
    pub skip_extensions: Vec<String>,
}

impl Default for WatchConfig {
    /// The recommended default: skip [`DEFAULT_SKIP_SUBSTRINGS`] +
    /// [`DEFAULT_SKIP_EXTENSIONS`]. Most consumers want this — see
    /// [`unfiltered`](Self::unfiltered) for the escape hatch.
    fn default() -> Self {
        Self {
            skip_substrings: DEFAULT_SKIP_SUBSTRINGS
                .iter()
                .map(|s| (*s).to_string())
                .collect(),
            skip_extensions: DEFAULT_SKIP_EXTENSIONS
                .iter()
                .map(|s| (*s).to_string())
                .collect(),
        }
    }
}

impl WatchConfig {
    /// Empty skip set — every event reaches the callback. Use when
    /// you genuinely want raw FS events (test fixtures, log-every-
    /// change diagnostic modes, or future consumers with a reason to
    /// see `.git/objects/...` writes).
    pub fn unfiltered() -> Self {
        Self {
            skip_substrings: Vec::new(),
            skip_extensions: Vec::new(),
        }
    }

    /// Test a path against the active skip set. `true` → skip; `false`
    /// → forward to callback. Public so consumers building their own
    /// orchestration over the same conventions can reuse the predicate
    /// without re-deriving it.
    pub fn is_skipped(&self, path: &Path) -> bool {
        // Substring match against the full path. UTF-8 fallback is
        // lossy: paths that aren't valid UTF-8 skip the substring
        // check (we still run the extension check below). On the
        // platforms we care about (macOS / Linux / Windows) this is
        // never the hot path's bottleneck.
        if let Some(s) = path.to_str() {
            for needle in &self.skip_substrings {
                if s.contains(needle.as_str()) {
                    return true;
                }
            }
        }
        if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
            for skip in &self.skip_extensions {
                if ext == skip {
                    return true;
                }
            }
        }
        false
    }
}

/// Apply a [`WatchConfig`]'s skip filter to a batch of event paths,
/// keeping only those that should reach the callback. The debouncer
/// drops the whole batch (no callback at all) when this returns empty —
/// the pure-noise-storm case (`cargo build`'s `target/` churn, a `git`
/// operation's `.git/` writes). Extracted as a free function so the
/// retention decision is unit-testable without depending on a real
/// watcher's platform-specific event-path semantics.
fn retain_unskipped(
    config: &WatchConfig,
    paths: impl IntoIterator<Item = PathBuf>,
) -> Vec<PathBuf> {
    paths
        .into_iter()
        .filter(|p| !config.is_skipped(p))
        .collect()
}

/// Active watcher handle. Drop to stop watching.
pub struct WatchHandle {
    _debouncer: Debouncer<notify_debouncer_mini::notify::RecommendedWatcher>,
}

/// Spawn a recursive debounced watcher on `dir` using the default
/// [`WatchConfig`] (skips conventional noise paths — `.git/`,
/// `target/`, `node_modules/`, etc.).
///
/// Returns a handle whose `Drop` impl tears the watcher down. Errors
/// surface synchronously if the path is not a directory or the platform
/// watcher refuses to register.
///
/// For control over the skip set, use [`watch_with_config`].
pub fn watch(
    dir: &Path,
    on_change: Option<ChangeHandler>,
    debounce: Option<Duration>,
) -> Result<WatchHandle> {
    watch_with_config(dir, on_change, debounce, WatchConfig::default())
}

/// Spawn a recursive debounced watcher with an explicit
/// [`WatchConfig`]. Behaves like [`watch`] except the skip set is
/// caller-controlled — pass [`WatchConfig::unfiltered`] to receive
/// every event, or build a custom config to add / remove patterns.
pub fn watch_with_config(
    dir: &Path,
    on_change: Option<ChangeHandler>,
    debounce: Option<Duration>,
    config: WatchConfig,
) -> Result<WatchHandle> {
    if !dir.is_dir() {
        anyhow::bail!("--watch path is not a directory: {}", dir.display());
    }
    let debounce = debounce.unwrap_or(DEFAULT_DEBOUNCE);
    let dir_for_log = dir.to_path_buf();
    let on_change = on_change.unwrap_or_else(|| {
        Arc::new(|_| {
            // No-op callback when no downstream consumer is configured.
        })
    });

    let mut debouncer = new_debouncer(debounce, move |result: DebounceEventResult| match result {
        Ok(events) => {
            // Drop skipped events before they're handed to the
            // callback or counted in the log line. Empty post-filter
            // batches (a pure-noise storm like `cargo build`'s
            // `target/` churn) return without a callback invocation
            // at all.
            let paths = retain_unskipped(&config, events.into_iter().map(|e| e.path));
            if paths.is_empty() {
                return;
            }
            tracing::info!(
                root = %dir_for_log.display(),
                changed = paths.len(),
                "watch: file change debounced"
            );
            on_change(&paths);
        }
        Err(e) => {
            tracing::warn!(error = %e, "watch: error from notify");
        }
    })
    .context("failed to construct file-system debouncer")?;

    debouncer
        .watcher()
        .watch(dir, RecursiveMode::Recursive)
        .with_context(|| format!("failed to watch {}", dir.display()))?;

    tracing::info!(root = %dir.display(), debounce_ms = debounce.as_millis() as u64, "watch: active");
    Ok(WatchHandle {
        _debouncer: debouncer,
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::atomic::{AtomicUsize, Ordering};

    #[test]
    fn watch_rejects_non_directory() {
        let result = watch(Path::new("/this/does/not/exist"), None, None);
        assert!(result.is_err());
    }

    #[test]
    fn watch_starts_and_drops_clean() {
        let dir = tempfile::tempdir().unwrap();
        let _handle = watch(dir.path(), None, Some(Duration::from_millis(100))).unwrap();
        // Drop at end of scope tears it down without panicking.
    }

    #[test]
    fn callback_fires_on_file_change() {
        use std::thread::sleep;
        let dir = tempfile::tempdir().unwrap();
        let counter = Arc::new(AtomicUsize::new(0));
        let counter_for_cb = counter.clone();
        let cb: ChangeHandler = Arc::new(move |_paths: &[PathBuf]| {
            counter_for_cb.fetch_add(1, Ordering::SeqCst);
        });
        let _handle = watch(dir.path(), Some(cb), Some(Duration::from_millis(100))).unwrap();
        sleep(Duration::from_millis(50)); // let watcher settle
        std::fs::write(dir.path().join("a.txt"), "hi").unwrap();
        sleep(Duration::from_millis(400)); // debounce + buffer
        assert!(
            counter.load(Ordering::SeqCst) >= 1,
            "expected callback to fire at least once after file write"
        );
    }

    // ── skip-pattern coverage ───────────────────────────────────────

    #[test]
    fn default_config_skips_git_dir() {
        let cfg = WatchConfig::default();
        assert!(cfg.is_skipped(Path::new("/repo/.git/HEAD")));
        assert!(cfg.is_skipped(Path::new("/repo/.git/objects/ab/cdef")));
    }

    #[test]
    fn default_config_skips_target_dir() {
        let cfg = WatchConfig::default();
        assert!(cfg.is_skipped(Path::new("/repo/target/debug/foo.rlib")));
        assert!(cfg.is_skipped(Path::new("/repo/target/release/build/x.o")));
    }

    #[test]
    fn default_config_skips_node_modules() {
        let cfg = WatchConfig::default();
        assert!(cfg.is_skipped(Path::new("/repo/node_modules/@scope/package/index.js")));
    }

    #[test]
    fn default_config_skips_python_bytecode() {
        let cfg = WatchConfig::default();
        assert!(cfg.is_skipped(Path::new("/repo/pkg/__pycache__/m.cpython-312.pyc")));
        assert!(cfg.is_skipped(Path::new("/repo/lib.pyc")));
    }

    #[test]
    fn default_config_skips_editor_swap() {
        let cfg = WatchConfig::default();
        assert!(cfg.is_skipped(Path::new("/repo/src/main.rs.swp")));
        assert!(cfg.is_skipped(Path::new("/repo/draft.tmp")));
    }

    #[test]
    fn default_config_passes_source_files() {
        let cfg = WatchConfig::default();
        // Files with these patterns OUTSIDE the skip dirs should pass.
        assert!(!cfg.is_skipped(Path::new("/repo/src/main.rs")));
        assert!(!cfg.is_skipped(Path::new("/repo/lib.py")));
        assert!(!cfg.is_skipped(Path::new("/repo/index.ts")));
        // A literal `.gitignore` (not under `.git/`) should pass.
        assert!(!cfg.is_skipped(Path::new("/repo/.gitignore")));
    }

    #[test]
    fn unfiltered_config_skips_nothing() {
        let cfg = WatchConfig::unfiltered();
        assert!(!cfg.is_skipped(Path::new("/repo/.git/HEAD")));
        assert!(!cfg.is_skipped(Path::new("/repo/target/foo.rlib")));
        assert!(!cfg.is_skipped(Path::new("/repo/lib.pyc")));
    }

    #[test]
    fn custom_config_round_trip() {
        let cfg = WatchConfig {
            skip_substrings: vec!["/secret/".to_string()],
            skip_extensions: vec!["bak".to_string()],
        };
        assert!(cfg.is_skipped(Path::new("/repo/secret/key.txt")));
        assert!(cfg.is_skipped(Path::new("/repo/file.bak")));
        // Substrings from the default set are NOT in this config:
        assert!(!cfg.is_skipped(Path::new("/repo/.git/HEAD")));
        assert!(!cfg.is_skipped(Path::new("/repo/lib.pyc")));
    }

    #[test]
    fn default_skip_substrings_are_anchored() {
        let cfg = WatchConfig::default();
        // `/target/` (not `target/`) so a file literally named `target`
        // at the repo root doesn't false-match.
        assert!(!cfg.is_skipped(Path::new("/repo/target")));
        // But `/repo/target/...` does:
        assert!(cfg.is_skipped(Path::new("/repo/target/foo")));
    }

    // The debouncer fires the callback iff `retain_unskipped` returns a
    // non-empty batch. We test that retention decision directly rather
    // than against a live watcher: a real-FS "noise-only batch" test is
    // inherently flaky across platforms, because inotify (Linux) and
    // FSEvents (macOS) report different event paths for the same writes
    // (e.g. a write inside `target/` can surface a modify event on the
    // bare `target` directory entry on Linux but not on macOS). The
    // positive wiring is covered by `callback_fires_on_file_change`.

    #[test]
    fn noise_only_batch_retains_nothing() {
        let cfg = WatchConfig::default();
        // A pure `cargo build` / `git` storm — every path is noise.
        let batch = vec![
            PathBuf::from("/repo/target/debug/deps/a.rlib"),
            PathBuf::from("/repo/target/release/build/x.o"),
            PathBuf::from("/repo/.git/objects/ab/cdef"),
            PathBuf::from("/repo/pkg/__pycache__/m.cpython-312.pyc"),
            PathBuf::from("/repo/lib.pyc"),
        ];
        // Empty result → the debouncer returns without firing the callback.
        assert!(retain_unskipped(&cfg, batch).is_empty());
    }

    #[test]
    fn mixed_batch_retains_only_non_noise() {
        let cfg = WatchConfig::default();
        let batch = vec![
            PathBuf::from("/repo/target/debug/deps/a.rlib"), // noise
            PathBuf::from("/repo/src/main.rs"),              // source — keep
            PathBuf::from("/repo/.git/HEAD"),                // noise
            PathBuf::from("/repo/lib.py"),                   // source — keep
        ];
        let kept = retain_unskipped(&cfg, batch);
        assert_eq!(
            kept,
            vec![
                PathBuf::from("/repo/src/main.rs"),
                PathBuf::from("/repo/lib.py"),
            ]
        );
    }

    #[test]
    fn unfiltered_config_retains_everything() {
        let cfg = WatchConfig::unfiltered();
        let batch = vec![
            PathBuf::from("/repo/target/debug/a.rlib"),
            PathBuf::from("/repo/.git/HEAD"),
        ];
        // Nothing is dropped → the callback sees the raw batch.
        assert_eq!(retain_unskipped(&cfg, batch.clone()), batch);
    }
}