Skip to main content

snapper_fmt/
mcp.rs

1//! MCP (Model Context Protocol) server for snapper.
2//!
3//! Exposes formatting tools to MCP clients via the standard MCP protocol
4//! on stdin/stdout.
5
6use rmcp::handler::server::router::tool::ToolRouter;
7use rmcp::handler::server::wrapper::Parameters;
8use rmcp::{Json, ServerHandler, ServiceExt, tool, tool_router};
9use serde::{Deserialize, Serialize};
10
11use crate::FormatConfig;
12use crate::check::{DiagnosticKind, collect_diagnostics, resolve_long_threshold, would_reformat};
13use crate::format::Format;
14
15// -- Tool parameter types --
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, schemars::JsonSchema)]
18pub struct LineRange {
19    /// 1-indexed inclusive start line.
20    pub start: usize,
21    /// 1-indexed inclusive end line.
22    pub end: usize,
23}
24
25#[derive(Debug, Deserialize, schemars::JsonSchema)]
26pub struct FormatTextParams {
27    /// Text to format with semantic line breaks.
28    pub text: String,
29    /// Document format: "org", "latex", "markdown", "rst", or "plaintext".
30    #[serde(default = "default_format")]
31    pub format: String,
32    /// Maximum line width (0 = unlimited).
33    #[serde(default)]
34    pub max_width: usize,
35    /// Extra abbreviations that should not trigger sentence breaks.
36    #[serde(default)]
37    pub extra_abbreviations: Vec<String>,
38    /// Prefer soft breaks after independent-clause punctuation
39    /// (same as CLI `--clause-breaks`).
40    /// `max_width` 0 always breaks at whitespace after the punctuation;
41    /// `max_width` greater than 0 is wrap-prefer.
42    #[serde(default)]
43    pub clause_breaks: bool,
44    /// Optional 1-indexed inclusive line range. Same meaning as CLI `--range`.
45    #[serde(default)]
46    pub range: Option<LineRange>,
47}
48
49#[derive(Debug, Deserialize, schemars::JsonSchema)]
50pub struct DetectFormatParams {
51    /// Text to analyze for format detection.
52    pub text: String,
53}
54
55#[derive(Debug, Deserialize, schemars::JsonSchema)]
56pub struct CheckFormattingParams {
57    /// Text to check for semantic line break violations.
58    pub text: String,
59    /// Document format: "org", "latex", "markdown", "rst", or "plaintext".
60    #[serde(default = "default_format")]
61    pub format: String,
62    /// Maximum line width (0 = unlimited). Used as the `long` threshold when set.
63    #[serde(default)]
64    pub max_width: usize,
65    /// Prefer soft breaks after independent-clause punctuation
66    /// (same as CLI `--clause-breaks`; default false).
67    /// `max_width` 0 always breaks at whitespace after the punctuation;
68    /// `max_width` greater than 0 is wrap-prefer.
69    #[serde(default)]
70    pub clause_breaks: bool,
71}
72
73#[derive(Debug, Deserialize, schemars::JsonSchema)]
74pub struct SplitSentencesParams {
75    /// Text to split into individual sentences.
76    pub text: String,
77}
78
79fn default_format() -> String {
80    "plaintext".to_string()
81}
82
83fn parse_format(s: &str) -> Format {
84    Format::from_extension(s)
85}
86
87fn make_config(
88    format: Format,
89    max_width: usize,
90    extra_abbreviations: Vec<String>,
91    clause_breaks: bool,
92) -> FormatConfig {
93    FormatConfig {
94        format,
95        max_width,
96        extra_abbreviations,
97        clause_breaks,
98        ..Default::default()
99    }
100}
101
102// -- Response types --
103
104#[derive(Debug, Serialize, schemars::JsonSchema)]
105pub struct FormatTextResult {
106    pub formatted: String,
107}
108
109#[derive(Debug, Serialize, schemars::JsonSchema)]
110pub struct DetectFormatResult {
111    pub format: String,
112}
113
114#[derive(Debug, Serialize, schemars::JsonSchema)]
115pub struct LineDiagnosticDto {
116    /// 1-indexed source line.
117    pub line: usize,
118    /// `fused`, `wrap`, or `long`.
119    pub kind: String,
120    /// Source line excerpt.
121    pub excerpt: String,
122}
123
124#[derive(Debug, Serialize, schemars::JsonSchema)]
125pub struct CheckFormattingResult {
126    /// Line numbers (1-indexed) containing multiple sentences (fused).
127    pub violations: Vec<usize>,
128    /// Whether the text matches formatted output (same as CLI `--check` without `--strict-long`).
129    pub passed: bool,
130    /// True when `format_text` would change the input. Identical to CLI `--check`.
131    pub would_reformat: bool,
132    /// Line-level fused / wrap / long diagnostics.
133    pub diagnostics: Vec<LineDiagnosticDto>,
134}
135
136#[derive(Debug, Serialize, schemars::JsonSchema)]
137pub struct SplitSentencesResult {
138    pub sentences: Vec<String>,
139}
140
141// -- Server --
142
143pub struct SnapperMcpServer {
144    /// Held for `#[tool_router]` / `ServerHandler` generated accessors.
145    #[allow(dead_code)]
146    tool_router: ToolRouter<Self>,
147}
148
149impl SnapperMcpServer {
150    pub fn new() -> Self {
151        Self {
152            tool_router: Self::tool_router(),
153        }
154    }
155}
156
157impl Default for SnapperMcpServer {
158    fn default() -> Self {
159        Self::new()
160    }
161}
162
163#[tool_router]
164impl SnapperMcpServer {
165    #[tool(
166        name = "format_text",
167        description = "Format text with semantic line breaks. Each sentence is placed on its own line, producing minimal git diffs. Preserves math, tables, and other structure; source-block fences stay fixed while configured language comments reflow (optional external formatters are CLI-only via --format-code). Supports clause_breaks and an optional 1-indexed range (same as the CLI)."
168    )]
169    fn format_text(
170        &self,
171        Parameters(params): Parameters<FormatTextParams>,
172    ) -> Result<Json<FormatTextResult>, rmcp::ErrorData> {
173        let format = parse_format(&params.format);
174        let config = make_config(
175            format,
176            params.max_width,
177            params.extra_abbreviations,
178            params.clause_breaks,
179        );
180        let result = if let Some(range) = params.range {
181            crate::format_range(&params.text, &config, range.start, range.end)
182        } else {
183            crate::format_text(&params.text, &config)
184        };
185        match result {
186            Ok(formatted) => Ok(Json(FormatTextResult { formatted })),
187            Err(e) => Err(rmcp::ErrorData::internal_error(
188                format!("formatting failed: {e}"),
189                None,
190            )),
191        }
192    }
193
194    #[tool(
195        name = "detect_format",
196        description = "Detect the document format of text using content heuristics. Returns one of: org, latex, markdown, rst, plaintext."
197    )]
198    fn detect_format(
199        &self,
200        Parameters(params): Parameters<DetectFormatParams>,
201    ) -> Json<DetectFormatResult> {
202        let format = detect_format_heuristic(&params.text);
203        Json(DetectFormatResult {
204            format: format_name(format),
205        })
206    }
207
208    #[tool(
209        name = "check_formatting",
210        description = "Check text for semantic line break violations. Honors clause_breaks (same two-mode contract as format_text). Returns would_reformat (identical to CLI --check), line diagnostics (fused/wrap/long), and fused line numbers."
211    )]
212    fn check_formatting(
213        &self,
214        Parameters(params): Parameters<CheckFormattingParams>,
215    ) -> Json<CheckFormattingResult> {
216        let format = parse_format(&params.format);
217        let config = make_config(format, params.max_width, vec![], params.clause_breaks);
218        let splitter = crate::build_splitter(&config).unwrap();
219        let would = would_reformat(&params.text, &config).unwrap_or(true);
220        let threshold = resolve_long_threshold(params.max_width, None);
221        let diagnostics = collect_diagnostics(
222            &params.text,
223            format,
224            splitter.as_ref(),
225            threshold,
226            Some(&config),
227        );
228        let violations: Vec<usize> = diagnostics
229            .iter()
230            .filter(|d| d.kind == DiagnosticKind::Fused)
231            .map(|d| d.line)
232            .collect();
233        let dto = diagnostics
234            .into_iter()
235            .map(|d| LineDiagnosticDto {
236                line: d.line,
237                kind: d.kind.as_str().to_string(),
238                excerpt: d.excerpt,
239            })
240            .collect();
241        Json(CheckFormattingResult {
242            violations,
243            passed: !would,
244            would_reformat: would,
245            diagnostics: dto,
246        })
247    }
248
249    #[tool(
250        name = "split_sentences",
251        description = "Split text into individual sentences using Unicode-aware sentence boundary detection with abbreviation handling."
252    )]
253    fn split_sentences(
254        &self,
255        Parameters(params): Parameters<SplitSentencesParams>,
256    ) -> Json<SplitSentencesResult> {
257        let config = FormatConfig::default();
258        let splitter = crate::build_splitter(&config).unwrap();
259        let sentences = splitter.split(&params.text);
260        Json(SplitSentencesResult { sentences })
261    }
262}
263
264impl ServerHandler for SnapperMcpServer {}
265
266// -- Helpers --
267
268/// Heuristic format detection from text content.
269fn detect_format_heuristic(input: &str) -> Format {
270    let lines: Vec<&str> = input.lines().take(20).collect();
271
272    if input.contains("\\begin{")
273        || input.contains("\\section{")
274        || input.contains("\\documentclass")
275    {
276        return Format::Latex;
277    }
278
279    if lines
280        .iter()
281        .any(|l| l.starts_with("#+") || l.starts_with("* "))
282        && (input.contains(":PROPERTIES:") || input.contains(":END:") || input.contains("#+begin_"))
283    {
284        return Format::Org;
285    }
286
287    if lines
288        .iter()
289        .any(|l| l.starts_with("# ") || l.starts_with("## "))
290    {
291        return Format::Markdown;
292    }
293
294    if input.contains(".. ")
295        || lines
296            .iter()
297            .any(|l| l.chars().all(|c| c == '=' || c == '-') && l.len() > 3)
298    {
299        return Format::Rst;
300    }
301
302    Format::Plaintext
303}
304
305fn format_name(f: Format) -> String {
306    match f {
307        Format::Org => "org",
308        Format::Latex => "latex",
309        Format::Markdown => "markdown",
310        Format::Rst => "rst",
311        Format::Plaintext => "plaintext",
312    }
313    .to_string()
314}
315
316/// Run the MCP server on stdin/stdout.
317pub async fn run_mcp() -> anyhow::Result<()> {
318    let server = SnapperMcpServer::new();
319    let transport = rmcp::transport::io::stdio();
320    let running = server
321        .serve(transport)
322        .await
323        .map_err(|e| anyhow::anyhow!("MCP server failed to start: {e}"))?;
324    running.waiting().await?;
325    Ok(())
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    fn format(params: FormatTextParams) -> String {
333        let server = SnapperMcpServer::new();
334        server
335            .format_text(Parameters(params))
336            .expect("format_text")
337            .0
338            .formatted
339    }
340
341    fn plaintext(text: &str) -> FormatTextParams {
342        FormatTextParams {
343            text: text.to_string(),
344            format: "plaintext".to_string(),
345            max_width: 0,
346            extra_abbreviations: vec![],
347            clause_breaks: false,
348            range: None,
349        }
350    }
351
352    fn check(text: &str) -> CheckFormattingResult {
353        check_with(text, false)
354    }
355
356    fn check_with(text: &str, clause_breaks: bool) -> CheckFormattingResult {
357        let server = SnapperMcpServer::new();
358        server
359            .check_formatting(Parameters(CheckFormattingParams {
360                text: text.to_string(),
361                format: "plaintext".to_string(),
362                max_width: 0,
363                clause_breaks,
364            }))
365            .0
366    }
367
368    #[test]
369    fn default_features_include_mcp() {
370        let manifest = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml"));
371        let after = manifest
372            .split("[features]")
373            .nth(1)
374            .expect("Cargo.toml [features]");
375        let default_line = after
376            .lines()
377            .find(|l| l.starts_with("default"))
378            .expect("default = [...]");
379        assert!(
380            default_line.contains("\"mcp\""),
381            "default features must include mcp so release binaries ship the server: {default_line}"
382        );
383    }
384
385    #[test]
386    fn dist_workspace_does_not_strip_mcp() {
387        let dist = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/dist-workspace.toml"));
388        assert!(
389            !dist.contains("no-default-features")
390                && !dist.contains("default-features")
391                && !dist.lines().any(|l| l.contains("features")
392                    && !l.contains("cargo-dist-version")
393                    && !l.trim_start().starts_with('#')),
394            "dist-workspace.toml must not override default features (mcp ships via Cargo.toml default)"
395        );
396    }
397
398    #[test]
399    fn format_text_params_max_width_defaults_to_zero() {
400        let params: FormatTextParams = serde_json::from_str(r#"{"text":"Hi."}"#).unwrap();
401        assert_eq!(params.max_width, 0);
402        assert!(!params.clause_breaks);
403        assert!(params.range.is_none());
404    }
405
406    #[test]
407    fn format_text_params_accept_clause_breaks_range_and_max_width() {
408        let params: FormatTextParams = serde_json::from_str(
409            r#"{
410                "text": "Hi.",
411                "clause_breaks": true,
412                "range": {"start": 2, "end": 3},
413                "max_width": 80
414            }"#,
415        )
416        .unwrap();
417        assert!(params.clause_breaks);
418        assert_eq!(params.range, Some(LineRange { start: 2, end: 3 }));
419        assert_eq!(params.max_width, 80);
420    }
421
422    #[test]
423    fn format_text_clause_breaks_wraps_after_commas() {
424        let sentence = "It contains rules which govern how the Objectives are orchestrated, along with rules which can automatically activate the Objectives in the plan, without additional human intervention.";
425        let mut params = plaintext(sentence);
426        params.max_width = 80;
427        params.clause_breaks = true;
428        let out = format(params);
429        assert!(
430            out.contains("orchestrated,\nalong with"),
431            "clause_breaks must break after first comma: {out:?}"
432        );
433        assert!(
434            out.contains("plan,\nwithout"),
435            "clause_breaks must break after second comma: {out:?}"
436        );
437    }
438
439    #[test]
440    fn format_text_clause_breaks_unlimited_breaks_after_commas() {
441        let sentence = "It contains rules which govern how the Objectives are orchestrated, along with rules which can automatically activate the Objectives in the plan, without additional human intervention.";
442        let mut params = plaintext(sentence);
443        params.clause_breaks = true;
444        let out = format(params);
445        assert!(
446            out.contains("orchestrated,\nalong with"),
447            "clause_breaks with max_width 0 must break after first comma: {out:?}"
448        );
449        assert!(
450            out.contains("plan,\nwithout"),
451            "clause_breaks with max_width 0 must break after second comma: {out:?}"
452        );
453    }
454
455    #[test]
456    fn format_text_range_formats_only_specified_lines() {
457        let mut params = plaintext(
458            "Line one. Stay same.\nLine two. Should split. Into two.\nLine three. Stay same.\n",
459        );
460        params.range = Some(LineRange { start: 2, end: 2 });
461        let out = format(params);
462        assert!(
463            out.starts_with("Line one. Stay same.\n"),
464            "lines before range stay: {out:?}"
465        );
466        assert!(
467            out.contains("Line two.\nShould split.\nInto two.\n"),
468            "range line must reflow: {out:?}"
469        );
470        assert!(
471            out.ends_with("Line three. Stay same.\n"),
472            "lines after range stay: {out:?}"
473        );
474    }
475
476    #[test]
477    fn check_formatting_would_reformat_matches_cli_check() {
478        let fused = check("Hello world. This is a test.\n");
479        assert!(
480            fused.would_reformat,
481            "fused input must match CLI --check dirty"
482        );
483        assert!(!fused.passed);
484        assert_eq!(fused.violations, vec![1]);
485
486        let ok = check("Hello world.\nThis is a test.\n");
487        assert!(
488            !ok.would_reformat,
489            "already-formatted input must match CLI --check clean"
490        );
491        assert!(ok.passed);
492        assert!(ok.violations.is_empty());
493    }
494
495    #[test]
496    fn check_formatting_params_clause_breaks_defaults_false() {
497        let params: CheckFormattingParams = serde_json::from_str(r#"{"text":"Hi."}"#).unwrap();
498        assert!(!params.clause_breaks);
499        assert_eq!(params.max_width, 0);
500    }
501
502    #[test]
503    fn check_formatting_clause_breaks_matches_format_text() {
504        let fused = check_with("Hello, world.\n", true);
505        assert!(
506            fused.would_reformat,
507            "fused clause with clause_breaks must be would_reformat"
508        );
509
510        let broken = check_with("Hello,\nworld.\n", true);
511        assert!(
512            !broken.would_reformat,
513            "already-broken clauses must be clean"
514        );
515    }
516}