use rmcp::handler::server::router::tool::ToolRouter;
use rmcp::handler::server::wrapper::Parameters;
use rmcp::{Json, ServerHandler, ServiceExt, tool, tool_router};
use serde::{Deserialize, Serialize};
use crate::FormatConfig;
use crate::check::{DiagnosticKind, collect_diagnostics, resolve_long_threshold, would_reformat};
use crate::format::Format;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, schemars::JsonSchema)]
pub struct LineRange {
pub start: usize,
pub end: usize,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct FormatTextParams {
pub text: String,
#[serde(default = "default_format")]
pub format: String,
#[serde(default)]
pub max_width: usize,
#[serde(default)]
pub extra_abbreviations: Vec<String>,
#[serde(default)]
pub clause_breaks: bool,
#[serde(default)]
pub range: Option<LineRange>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct DetectFormatParams {
pub text: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct CheckFormattingParams {
pub text: String,
#[serde(default = "default_format")]
pub format: String,
#[serde(default)]
pub max_width: usize,
#[serde(default)]
pub clause_breaks: bool,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct SplitSentencesParams {
pub text: String,
}
fn default_format() -> String {
"plaintext".to_string()
}
fn parse_format(s: &str) -> Format {
Format::from_extension(s)
}
fn make_config(
format: Format,
max_width: usize,
extra_abbreviations: Vec<String>,
clause_breaks: bool,
) -> FormatConfig {
FormatConfig {
format,
max_width,
extra_abbreviations,
clause_breaks,
..Default::default()
}
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct FormatTextResult {
pub formatted: String,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct DetectFormatResult {
pub format: String,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct LineDiagnosticDto {
pub line: usize,
pub kind: String,
pub excerpt: String,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct CheckFormattingResult {
pub violations: Vec<usize>,
pub passed: bool,
pub would_reformat: bool,
pub diagnostics: Vec<LineDiagnosticDto>,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct SplitSentencesResult {
pub sentences: Vec<String>,
}
pub struct SnapperMcpServer {
#[allow(dead_code)]
tool_router: ToolRouter<Self>,
}
impl SnapperMcpServer {
pub fn new() -> Self {
Self {
tool_router: Self::tool_router(),
}
}
}
impl Default for SnapperMcpServer {
fn default() -> Self {
Self::new()
}
}
#[tool_router]
impl SnapperMcpServer {
#[tool(
name = "format_text",
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)."
)]
fn format_text(
&self,
Parameters(params): Parameters<FormatTextParams>,
) -> Result<Json<FormatTextResult>, rmcp::ErrorData> {
let format = parse_format(¶ms.format);
let config = make_config(
format,
params.max_width,
params.extra_abbreviations,
params.clause_breaks,
);
let result = if let Some(range) = params.range {
crate::format_range(¶ms.text, &config, range.start, range.end)
} else {
crate::format_text(¶ms.text, &config)
};
match result {
Ok(formatted) => Ok(Json(FormatTextResult { formatted })),
Err(e) => Err(rmcp::ErrorData::internal_error(
format!("formatting failed: {e}"),
None,
)),
}
}
#[tool(
name = "detect_format",
description = "Detect the document format of text using content heuristics. Returns one of: org, latex, markdown, rst, plaintext."
)]
fn detect_format(
&self,
Parameters(params): Parameters<DetectFormatParams>,
) -> Json<DetectFormatResult> {
let format = detect_format_heuristic(¶ms.text);
Json(DetectFormatResult {
format: format_name(format),
})
}
#[tool(
name = "check_formatting",
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."
)]
fn check_formatting(
&self,
Parameters(params): Parameters<CheckFormattingParams>,
) -> Json<CheckFormattingResult> {
let format = parse_format(¶ms.format);
let config = make_config(format, params.max_width, vec![], params.clause_breaks);
let splitter = crate::build_splitter(&config).unwrap();
let would = would_reformat(¶ms.text, &config).unwrap_or(true);
let threshold = resolve_long_threshold(params.max_width, None);
let diagnostics = collect_diagnostics(
¶ms.text,
format,
splitter.as_ref(),
threshold,
Some(&config),
);
let violations: Vec<usize> = diagnostics
.iter()
.filter(|d| d.kind == DiagnosticKind::Fused)
.map(|d| d.line)
.collect();
let dto = diagnostics
.into_iter()
.map(|d| LineDiagnosticDto {
line: d.line,
kind: d.kind.as_str().to_string(),
excerpt: d.excerpt,
})
.collect();
Json(CheckFormattingResult {
violations,
passed: !would,
would_reformat: would,
diagnostics: dto,
})
}
#[tool(
name = "split_sentences",
description = "Split text into individual sentences using Unicode-aware sentence boundary detection with abbreviation handling."
)]
fn split_sentences(
&self,
Parameters(params): Parameters<SplitSentencesParams>,
) -> Json<SplitSentencesResult> {
let config = FormatConfig::default();
let splitter = crate::build_splitter(&config).unwrap();
let sentences = splitter.split(¶ms.text);
Json(SplitSentencesResult { sentences })
}
}
impl ServerHandler for SnapperMcpServer {}
fn detect_format_heuristic(input: &str) -> Format {
let lines: Vec<&str> = input.lines().take(20).collect();
if input.contains("\\begin{")
|| input.contains("\\section{")
|| input.contains("\\documentclass")
{
return Format::Latex;
}
if lines
.iter()
.any(|l| l.starts_with("#+") || l.starts_with("* "))
&& (input.contains(":PROPERTIES:") || input.contains(":END:") || input.contains("#+begin_"))
{
return Format::Org;
}
if lines
.iter()
.any(|l| l.starts_with("# ") || l.starts_with("## "))
{
return Format::Markdown;
}
if input.contains(".. ")
|| lines
.iter()
.any(|l| l.chars().all(|c| c == '=' || c == '-') && l.len() > 3)
{
return Format::Rst;
}
Format::Plaintext
}
fn format_name(f: Format) -> String {
match f {
Format::Org => "org",
Format::Latex => "latex",
Format::Markdown => "markdown",
Format::Rst => "rst",
Format::Plaintext => "plaintext",
}
.to_string()
}
pub async fn run_mcp() -> anyhow::Result<()> {
let server = SnapperMcpServer::new();
let transport = rmcp::transport::io::stdio();
let running = server
.serve(transport)
.await
.map_err(|e| anyhow::anyhow!("MCP server failed to start: {e}"))?;
running.waiting().await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn format(params: FormatTextParams) -> String {
let server = SnapperMcpServer::new();
server
.format_text(Parameters(params))
.expect("format_text")
.0
.formatted
}
fn plaintext(text: &str) -> FormatTextParams {
FormatTextParams {
text: text.to_string(),
format: "plaintext".to_string(),
max_width: 0,
extra_abbreviations: vec![],
clause_breaks: false,
range: None,
}
}
fn check(text: &str) -> CheckFormattingResult {
check_with(text, false)
}
fn check_with(text: &str, clause_breaks: bool) -> CheckFormattingResult {
let server = SnapperMcpServer::new();
server
.check_formatting(Parameters(CheckFormattingParams {
text: text.to_string(),
format: "plaintext".to_string(),
max_width: 0,
clause_breaks,
}))
.0
}
#[test]
fn default_features_include_mcp() {
let manifest = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml"));
let after = manifest
.split("[features]")
.nth(1)
.expect("Cargo.toml [features]");
let default_line = after
.lines()
.find(|l| l.starts_with("default"))
.expect("default = [...]");
assert!(
default_line.contains("\"mcp\""),
"default features must include mcp so release binaries ship the server: {default_line}"
);
}
#[test]
fn dist_workspace_does_not_strip_mcp() {
let dist = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/dist-workspace.toml"));
assert!(
!dist.contains("no-default-features")
&& !dist.contains("default-features")
&& !dist.lines().any(|l| l.contains("features")
&& !l.contains("cargo-dist-version")
&& !l.trim_start().starts_with('#')),
"dist-workspace.toml must not override default features (mcp ships via Cargo.toml default)"
);
}
#[test]
fn format_text_params_max_width_defaults_to_zero() {
let params: FormatTextParams = serde_json::from_str(r#"{"text":"Hi."}"#).unwrap();
assert_eq!(params.max_width, 0);
assert!(!params.clause_breaks);
assert!(params.range.is_none());
}
#[test]
fn format_text_params_accept_clause_breaks_range_and_max_width() {
let params: FormatTextParams = serde_json::from_str(
r#"{
"text": "Hi.",
"clause_breaks": true,
"range": {"start": 2, "end": 3},
"max_width": 80
}"#,
)
.unwrap();
assert!(params.clause_breaks);
assert_eq!(params.range, Some(LineRange { start: 2, end: 3 }));
assert_eq!(params.max_width, 80);
}
#[test]
fn format_text_clause_breaks_wraps_after_commas() {
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.";
let mut params = plaintext(sentence);
params.max_width = 80;
params.clause_breaks = true;
let out = format(params);
assert!(
out.contains("orchestrated,\nalong with"),
"clause_breaks must break after first comma: {out:?}"
);
assert!(
out.contains("plan,\nwithout"),
"clause_breaks must break after second comma: {out:?}"
);
}
#[test]
fn format_text_clause_breaks_unlimited_breaks_after_commas() {
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.";
let mut params = plaintext(sentence);
params.clause_breaks = true;
let out = format(params);
assert!(
out.contains("orchestrated,\nalong with"),
"clause_breaks with max_width 0 must break after first comma: {out:?}"
);
assert!(
out.contains("plan,\nwithout"),
"clause_breaks with max_width 0 must break after second comma: {out:?}"
);
}
#[test]
fn format_text_range_formats_only_specified_lines() {
let mut params = plaintext(
"Line one. Stay same.\nLine two. Should split. Into two.\nLine three. Stay same.\n",
);
params.range = Some(LineRange { start: 2, end: 2 });
let out = format(params);
assert!(
out.starts_with("Line one. Stay same.\n"),
"lines before range stay: {out:?}"
);
assert!(
out.contains("Line two.\nShould split.\nInto two.\n"),
"range line must reflow: {out:?}"
);
assert!(
out.ends_with("Line three. Stay same.\n"),
"lines after range stay: {out:?}"
);
}
#[test]
fn check_formatting_would_reformat_matches_cli_check() {
let fused = check("Hello world. This is a test.\n");
assert!(
fused.would_reformat,
"fused input must match CLI --check dirty"
);
assert!(!fused.passed);
assert_eq!(fused.violations, vec![1]);
let ok = check("Hello world.\nThis is a test.\n");
assert!(
!ok.would_reformat,
"already-formatted input must match CLI --check clean"
);
assert!(ok.passed);
assert!(ok.violations.is_empty());
}
#[test]
fn check_formatting_params_clause_breaks_defaults_false() {
let params: CheckFormattingParams = serde_json::from_str(r#"{"text":"Hi."}"#).unwrap();
assert!(!params.clause_breaks);
assert_eq!(params.max_width, 0);
}
#[test]
fn check_formatting_clause_breaks_matches_format_text() {
let fused = check_with("Hello, world.\n", true);
assert!(
fused.would_reformat,
"fused clause with clause_breaks must be would_reformat"
);
let broken = check_with("Hello,\nworld.\n", true);
assert!(
!broken.would_reformat,
"already-broken clauses must be clean"
);
}
}