patchloom 0.23.0

Structured file editing library and CLI for AI agents: parser-backed JSON/YAML/TOML edits, AST-aware code operations, multi-file batching, markdown operations, and MCP server
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
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
//! MCP tool surface map: registry default vs justified custom tools.
//!
//! # Policy (MCP surface honesty)
//!
//! - **Default:** add tools via [`super::registry::MCP_TOOL_REGISTRY`] when the
//!   tool is a 1:1 mapping to a plan [`crate::plan::Operation`] with no special
//!   multi-file preflight, multi-op batching, or non-plan read UX.
//! - **Custom (hand-written `#[tool]`):** only when the tool is *not* a simple
//!   Operation write. Every custom tool must appear in the feature-aware
//!   inventory ([`CUSTOM_MCP_TOOLS_CORE`] + optional [`CUSTOM_MCP_TOOLS_AST`])
//!   with a one-line reason. Use [`custom_mcp_tools`] / [`custom_tool_names`]
//!   for the active feature set.
//! - **Metric:** "no unjustified custom tools," not "fewer custom tools."
//!   Forcing search/batch/AST-read into the registry would fight agent UX.
//!
//! Counts (with default features, including `ast`):
//! registry + custom = total tools exposed by `list_tools`.
//! Without `ast`, AST rows are omitted so inventory matches registration.
//!
//! Inventory is consumed by unit tests and documentation; it is intentionally
//! not wired into every production call path.

// Inventory for tests/docs; not every item is referenced outside `#[cfg(test)]`.
#![allow(dead_code)]

/// Why a tool is hand-written instead of registry-generated.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct CustomMcpTool {
    pub name: &'static str,
    /// One-line justification (stable; tested for presence, not exact wording drift).
    pub why: &'static str,
    pub kind: CustomKind,
}

/// Coarse category for custom tools (documentation + tests).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum CustomKind {
    /// Read-only structured doc queries (not write Operations).
    DocReadonly,
    /// Multi-file / parallel / CLI-shaped search or replace.
    MultiFileOrScan,
    /// Multi-op batch or full plan execution.
    MultiOp,
    /// Markdown helpers that need custom output or non-registry shapes.
    MdCustom,
    /// Patch apply with conflict/stale UX.
    Patch,
    /// AST analyze/mutate via tree-sitter (mostly non-plan or custom resolve).
    Ast,
    /// Server metadata / workspace discovery.
    Meta,
}

/// Hand-written tools that are always registered (no `ast` feature required).
///
/// When adding a custom `#[tool]` handler that is not AST-gated, add a row here
/// in the same PR. When moving a tool to the registry, remove its row.
pub(super) const CUSTOM_MCP_TOOLS_CORE: &[CustomMcpTool] = &[
    // --- Doc readonly ---
    CustomMcpTool {
        name: "doc_get",
        why: "readonly doc get; not a write Operation",
        kind: CustomKind::DocReadonly,
    },
    CustomMcpTool {
        name: "doc_query",
        why: "readonly multi-action query (has/keys/len/select/flatten)",
        kind: CustomKind::DocReadonly,
    },
    CustomMcpTool {
        name: "doc_diff",
        why: "readonly structured file compare",
        kind: CustomKind::DocReadonly,
    },
    // --- Multi-file / scan ---
    CustomMcpTool {
        name: "search_files",
        why: "multi-path search with layered ignores and report modes",
        kind: CustomKind::MultiFileOrScan,
    },
    CustomMcpTool {
        name: "replace_text",
        why: "parallel multi-file scan + precomputed engine handoff",
        kind: CustomKind::MultiFileOrScan,
    },
    // --- Multi-op ---
    CustomMcpTool {
        name: "batch_replace",
        why: "builds multi-file replace batch, not one Operation",
        kind: CustomKind::MultiOp,
    },
    CustomMcpTool {
        name: "batch_tidy",
        why: "builds multi-file tidy batch, not one Operation",
        kind: CustomKind::MultiOp,
    },
    CustomMcpTool {
        name: "execute_plan",
        why: "full transaction plan (inline or path), not one Operation",
        kind: CustomKind::MultiOp,
    },
    // --- Md custom ---
    CustomMcpTool {
        name: "md_move_section",
        why: "cross-file section move + custom result shape",
        kind: CustomKind::MdCustom,
    },
    CustomMcpTool {
        name: "md_lint",
        why: "readonly AGENTS.md lint; not a write Operation",
        kind: CustomKind::MdCustom,
    },
    // --- Patch ---
    CustomMcpTool {
        name: "apply_patch",
        why: "unified-diff apply with stale/conflict exit mapping",
        kind: CustomKind::Patch,
    },
    // --- Meta ---
    CustomMcpTool {
        name: "git_status",
        why: "readonly git status vs HEAD",
        kind: CustomKind::Meta,
    },
    CustomMcpTool {
        name: "server_info",
        why: "server/workspace metadata for agents",
        kind: CustomKind::Meta,
    },
];

/// AST custom tools; only registered when the `ast` feature is enabled.
#[cfg(feature = "ast")]
pub(super) const CUSTOM_MCP_TOOLS_AST: &[CustomMcpTool] = &[
    CustomMcpTool {
        name: "ast_list",
        why: "AST symbol listing (analyze, not plan write)",
        kind: CustomKind::Ast,
    },
    CustomMcpTool {
        name: "ast_read",
        why: "AST symbol body read",
        kind: CustomKind::Ast,
    },
    CustomMcpTool {
        name: "ast_rename",
        why: "AST multi-file rename with scan/filter then stage",
        kind: CustomKind::Ast,
    },
    CustomMcpTool {
        name: "ast_validate",
        why: "AST syntax validation report",
        kind: CustomKind::Ast,
    },
    CustomMcpTool {
        name: "ast_search",
        why: "structural AST search",
        kind: CustomKind::Ast,
    },
    CustomMcpTool {
        name: "ast_refs",
        why: "cross-file symbol references",
        kind: CustomKind::Ast,
    },
    CustomMcpTool {
        name: "ast_deps",
        why: "import/dependency extraction",
        kind: CustomKind::Ast,
    },
    CustomMcpTool {
        name: "ast_map",
        why: "repo map / PageRank over symbols",
        kind: CustomKind::Ast,
    },
    CustomMcpTool {
        name: "ast_diff",
        why: "structural symbol diff across git refs",
        kind: CustomKind::Ast,
    },
    CustomMcpTool {
        name: "ast_impact",
        why: "transitive impact analysis",
        kind: CustomKind::Ast,
    },
    CustomMcpTool {
        name: "ast_replace",
        why: "symbol-scoped replace with custom resolve/output",
        kind: CustomKind::Ast,
    },
    CustomMcpTool {
        name: "ast_rewrite_signature",
        why: "function signature rewrite (structured + full text)",
        kind: CustomKind::Ast,
    },
    CustomMcpTool {
        name: "ast_insert",
        why: "AST insert with position/container resolve",
        kind: CustomKind::Ast,
    },
    CustomMcpTool {
        name: "ast_wrap",
        why: "AST wrap with container resolve",
        kind: CustomKind::Ast,
    },
    CustomMcpTool {
        name: "ast_imports",
        why: "import list/add/remove/dedupe actions",
        kind: CustomKind::Ast,
    },
    CustomMcpTool {
        name: "ast_reorder",
        why: "symbol reorder strategies",
        kind: CustomKind::Ast,
    },
    CustomMcpTool {
        name: "ast_group",
        why: "group symbols into module blocks",
        kind: CustomKind::Ast,
    },
    CustomMcpTool {
        name: "ast_move",
        why: "move symbols across files",
        kind: CustomKind::Ast,
    },
    CustomMcpTool {
        name: "ast_extract_to_file",
        why: "extract symbol to new file",
        kind: CustomKind::Ast,
    },
    CustomMcpTool {
        name: "ast_split",
        why: "split file across targets by symbols",
        kind: CustomKind::Ast,
    },
];

#[cfg(not(feature = "ast"))]
pub(super) const CUSTOM_MCP_TOOLS_AST: &[CustomMcpTool] = &[];

/// All custom tools for the current feature set (core + optional AST).
pub(super) fn custom_mcp_tools() -> impl Iterator<Item = &'static CustomMcpTool> {
    CUSTOM_MCP_TOOLS_CORE
        .iter()
        .chain(CUSTOM_MCP_TOOLS_AST.iter())
}

/// Names of custom tools for the current feature set (set algebra in tests).
pub(super) fn custom_tool_names() -> impl Iterator<Item = &'static str> {
    custom_mcp_tools().map(|t| t.name)
}

// ---------------------------------------------------------------------------
// Progressive surface (PATCHLOOM_MCP_SURFACE) — #1994
// ---------------------------------------------------------------------------

/// Env var hosts set for a smaller MCP register set at handshake.
pub(super) const MCP_SURFACE_ENV: &str = "PATCHLOOM_MCP_SURFACE";

/// Tools registered when [`McpSurface::Core`] is active.
///
/// Keep this list small and agent-default: read/search/replace, doc get/set/query,
/// one markdown write, plans, and server metadata. AST stays full-only.
///
/// Documented in `docs/plans/mcp-surface-tiers.md` and agent-rules.
pub(super) const CORE_MCP_TOOL_NAMES: &[&str] = &[
    "read_file",
    "search_files",
    "replace_text",
    "batch_replace",
    "doc_get",
    "doc_set",
    "doc_query",
    "md_replace_section",
    "execute_plan",
    "server_info",
];

/// Which tool inventory the MCP server registers.
///
/// Default is [`McpSurface::Full`] (backward compatible). Hosts that want a
/// smaller schema for small agents set `PATCHLOOM_MCP_SURFACE=core`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) enum McpSurface {
    /// All registry + custom tools for the build features (default).
    #[default]
    Full,
    /// Minimal pack: [`CORE_MCP_TOOL_NAMES`] only (no AST).
    Core,
}

impl McpSurface {
    /// Parse `PATCHLOOM_MCP_SURFACE` (unset/`full` → Full, `core` → Core).
    pub(super) fn from_env() -> anyhow::Result<Self> {
        match std::env::var(MCP_SURFACE_ENV) {
            Err(std::env::VarError::NotPresent) => Ok(Self::Full),
            Err(e) => Err(anyhow::anyhow!("failed to read {MCP_SURFACE_ENV}: {e}")),
            Ok(raw) => Self::parse(&raw),
        }
    }

    /// Parse a surface string (case-insensitive). Empty means full.
    pub(super) fn parse(raw: &str) -> anyhow::Result<Self> {
        match raw.trim().to_ascii_lowercase().as_str() {
            "" | "full" => Ok(Self::Full),
            "core" => Ok(Self::Core),
            other => Err(anyhow::anyhow!(
                "invalid {MCP_SURFACE_ENV}={other:?}; expected \"core\" or \"full\""
            )),
        }
    }

    /// Wire / server_info string value.
    #[must_use]
    pub(super) fn as_str(self) -> &'static str {
        match self {
            Self::Full => "full",
            Self::Core => "core",
        }
    }

    /// Whether `tool_name` is registered on this surface.
    #[must_use]
    pub(super) fn allows(self, tool_name: &str) -> bool {
        match self {
            Self::Full => true,
            Self::Core => CORE_MCP_TOOL_NAMES.contains(&tool_name),
        }
    }

    /// Expected `list_tools` count for this surface under current features.
    #[must_use]
    pub(super) fn expected_tool_count(self) -> usize {
        match self {
            Self::Full => {
                let registry_n = super::registry::MCP_TOOL_REGISTRY.len();
                let custom_n = custom_mcp_tools().count();
                registry_n + custom_n
            }
            Self::Core => CORE_MCP_TOOL_NAMES.len(),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::super::registry::MCP_TOOL_REGISTRY;
    use super::*;
    use std::collections::BTreeSet;

    #[test]
    fn custom_tool_names_are_unique() {
        let mut seen = BTreeSet::new();
        for t in custom_mcp_tools() {
            assert!(
                seen.insert(t.name),
                "duplicate custom tool name: {}",
                t.name
            );
            assert!(!t.why.is_empty(), "{} missing why", t.name);
        }
    }

    #[test]
    fn registry_and_custom_are_disjoint() {
        let registry: BTreeSet<_> = MCP_TOOL_REGISTRY.iter().map(|t| t.tool_name).collect();
        let custom: BTreeSet<_> = custom_tool_names().collect();
        let overlap: Vec<_> = registry.intersection(&custom).copied().collect();
        assert!(
            overlap.is_empty(),
            "tool(s) listed as both registry and custom: {overlap:?}"
        );
    }

    #[test]
    fn registry_plus_custom_count_matches_list_tools_expectation() {
        // Core tools always; AST tools only with `ast` (matches list_tools registration).
        let registry_n = MCP_TOOL_REGISTRY.len();
        let custom_n = custom_mcp_tools().count();
        let expected_total = if cfg!(feature = "ast") { 57 } else { 37 };
        assert_eq!(
            registry_n + custom_n,
            expected_total,
            "registry ({registry_n}) + custom ({custom_n}) must equal total MCP tools ({expected_total})"
        );
        assert_eq!(CUSTOM_MCP_TOOLS_CORE.len(), 13, "core custom tool count");
        #[cfg(feature = "ast")]
        assert_eq!(CUSTOM_MCP_TOOLS_AST.len(), 20, "ast custom tool count");
        #[cfg(not(feature = "ast"))]
        assert!(CUSTOM_MCP_TOOLS_AST.is_empty());
    }

    #[test]
    fn fix_whitespace_is_registry_not_custom() {
        // Regression: fix_whitespace was promoted to registry (#1391 honesty target).
        assert!(
            MCP_TOOL_REGISTRY
                .iter()
                .any(|t| t.tool_name == "fix_whitespace")
        );
        assert!(!custom_tool_names().any(|n| n == "fix_whitespace"));
    }

    #[test]
    fn ast_custom_tools_are_feature_gated_in_inventory() {
        let names: BTreeSet<_> = custom_tool_names().collect();
        if cfg!(feature = "ast") {
            assert!(names.contains("ast_list"));
            assert!(names.contains("ast_split"));
        } else {
            assert!(!names.iter().any(|n| n.starts_with("ast_")));
        }
    }

    #[test]
    fn mcp_surface_parse_core_full_and_reject_unknown() {
        assert_eq!(McpSurface::parse("").unwrap(), McpSurface::Full);
        assert_eq!(McpSurface::parse("full").unwrap(), McpSurface::Full);
        assert_eq!(McpSurface::parse("FULL").unwrap(), McpSurface::Full);
        assert_eq!(McpSurface::parse("core").unwrap(), McpSurface::Core);
        assert_eq!(McpSurface::parse(" Core ").unwrap(), McpSurface::Core);
        let err = McpSurface::parse("minimal").unwrap_err().to_string();
        assert!(
            err.contains(MCP_SURFACE_ENV) && err.contains("core"),
            "got: {err}"
        );
    }

    #[test]
    fn core_mcp_tool_names_are_unique_and_exist_in_full_inventory() {
        let mut seen = BTreeSet::new();
        for name in CORE_MCP_TOOL_NAMES {
            assert!(seen.insert(*name), "duplicate core tool: {name}");
        }
        let registry: BTreeSet<_> = MCP_TOOL_REGISTRY.iter().map(|t| t.tool_name).collect();
        let custom: BTreeSet<_> = custom_tool_names().collect();
        for name in CORE_MCP_TOOL_NAMES {
            assert!(
                registry.contains(name) || custom.contains(name),
                "core tool {name} missing from registry and custom inventory"
            );
            // Core must not depend on AST-only tools.
            assert!(
                !name.starts_with("ast_"),
                "core surface must not include AST tool {name}"
            );
        }
        assert_eq!(
            McpSurface::Core.expected_tool_count(),
            CORE_MCP_TOOL_NAMES.len()
        );
        assert_eq!(
            McpSurface::Full.expected_tool_count(),
            MCP_TOOL_REGISTRY.len() + custom_mcp_tools().count()
        );
        // Sanity: core is much smaller than full with default features.
        assert!(
            McpSurface::Core.expected_tool_count() < McpSurface::Full.expected_tool_count(),
            "core pack must be a strict subset of full"
        );
    }

    #[test]
    fn core_surface_allows_only_core_names() {
        assert!(McpSurface::Core.allows("doc_set"));
        assert!(McpSurface::Core.allows("server_info"));
        assert!(!McpSurface::Core.allows("doc_diff"));
        assert!(!McpSurface::Core.allows("ast_list"));
        assert!(!McpSurface::Core.allows("create_file"));
        assert!(McpSurface::Full.allows("ast_list"));
        assert!(McpSurface::Full.allows("create_file"));
    }
}