1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, schemars::JsonSchema)]
18pub struct LineRange {
19 pub start: usize,
21 pub end: usize,
23}
24
25#[derive(Debug, Deserialize, schemars::JsonSchema)]
26pub struct FormatTextParams {
27 pub text: String,
29 #[serde(default = "default_format")]
31 pub format: String,
32 #[serde(default)]
34 pub max_width: usize,
35 #[serde(default)]
37 pub extra_abbreviations: Vec<String>,
38 #[serde(default)]
43 pub clause_breaks: bool,
44 #[serde(default)]
46 pub range: Option<LineRange>,
47}
48
49#[derive(Debug, Deserialize, schemars::JsonSchema)]
50pub struct DetectFormatParams {
51 pub text: String,
53}
54
55#[derive(Debug, Deserialize, schemars::JsonSchema)]
56pub struct CheckFormattingParams {
57 pub text: String,
59 #[serde(default = "default_format")]
61 pub format: String,
62 #[serde(default)]
64 pub max_width: usize,
65 #[serde(default)]
70 pub clause_breaks: bool,
71}
72
73#[derive(Debug, Deserialize, schemars::JsonSchema)]
74pub struct SplitSentencesParams {
75 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#[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 pub line: usize,
118 pub kind: String,
120 pub excerpt: String,
122}
123
124#[derive(Debug, Serialize, schemars::JsonSchema)]
125pub struct CheckFormattingResult {
126 pub violations: Vec<usize>,
128 pub passed: bool,
130 pub would_reformat: bool,
132 pub diagnostics: Vec<LineDiagnosticDto>,
134}
135
136#[derive(Debug, Serialize, schemars::JsonSchema)]
137pub struct SplitSentencesResult {
138 pub sentences: Vec<String>,
139}
140
141pub struct SnapperMcpServer {
144 #[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(¶ms.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(¶ms.text, &config, range.start, range.end)
182 } else {
183 crate::format_text(¶ms.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(¶ms.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(¶ms.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(¶ms.text, &config).unwrap_or(true);
220 let threshold = resolve_long_threshold(params.max_width, None);
221 let diagnostics = collect_diagnostics(
222 ¶ms.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(¶ms.text);
260 Json(SplitSentencesResult { sentences })
261 }
262}
263
264impl ServerHandler for SnapperMcpServer {}
265
266fn 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
316pub 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}