use crate::options::ConversionOptions;
use rmcp::{
ServerHandler, ServiceExt,
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
model::{
CallToolResult, Content, Implementation, InitializeResult, ServerCapabilities, ServerInfo, ToolsCapability,
},
tool, tool_handler, tool_router,
transport::stdio,
};
#[cfg(feature = "mcp-http")]
use rmcp::transport::streamable_http_server::{StreamableHttpService, session::local::LocalSessionManager};
#[cfg_attr(alef, alef(skip))]
#[derive(Clone)]
pub struct HtmlToMarkdownMcp {
#[allow(dead_code)]
tool_router: ToolRouter<Self>,
}
#[tool_router]
impl HtmlToMarkdownMcp {
pub(crate) fn new() -> Self {
Self {
tool_router: Self::tool_router(),
}
}
#[tool(
description = "Convert HTML to Markdown. Pass json:true for full ConversionResult (content, tables, metadata, warnings). Pass options as a JSON object to control heading style, list indent, escaping, etc.",
annotations(title = "Convert HTML", read_only_hint = true, idempotent_hint = true)
)]
async fn convert_html(
&self,
Parameters(params): Parameters<super::params::ConvertHtmlParams>,
) -> Result<CallToolResult, rmcp::ErrorData> {
use super::errors::map_conversion_error_to_mcp;
use super::format::format_conversion_result;
let opts: ConversionOptions = match params.options {
Some(v) => serde_json::from_value(v)
.map_err(|e| rmcp::ErrorData::invalid_params(format!("Invalid conversion options: {e}"), None))?,
None => ConversionOptions::default(),
};
let html = params.html;
let want_json = params.json;
let result = tokio::task::spawn_blocking(move || crate::convert(&html, opts))
.await
.map_err(|e| rmcp::ErrorData::internal_error(format!("Conversion task panicked: {e}"), None))?
.map_err(map_conversion_error_to_mcp)?;
let text = if want_json {
format_conversion_result(&result)
} else {
result.content.unwrap_or_default()
};
Ok(CallToolResult::success(vec![Content::text(text)]))
}
}
#[tool_handler]
impl ServerHandler for HtmlToMarkdownMcp {
fn get_info(&self) -> ServerInfo {
let mut capabilities = ServerCapabilities::default();
capabilities.tools = Some(ToolsCapability::default());
let server_info = Implementation::new("html-to-markdown-mcp", env!("CARGO_PKG_VERSION"))
.with_title("HTML-to-Markdown MCP Server")
.with_description(
"Fast, lossless HTML to Markdown conversion. \
Supports optional ConversionOptions for heading style, list formatting, \
escaping, metadata extraction, and more.",
)
.with_website_url("https://github.com/kreuzberg-dev/html-to-markdown");
InitializeResult::new(capabilities)
.with_server_info(server_info)
.with_instructions(
"Use the convert_html tool to convert HTML strings to Markdown. \
Pass json:true to receive the full ConversionResult (content, tables, \
document structure, metadata, warnings). \
Pass options as a JSON object to customise conversion (e.g., heading_style, \
escape_asterisks, extract_metadata).",
)
}
}
#[cfg_attr(alef, alef(skip))]
pub async fn start_mcp_server() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let service = HtmlToMarkdownMcp::new().serve(stdio()).await?;
service.waiting().await?;
Ok(())
}
#[cfg(feature = "mcp-http")]
#[cfg_attr(alef, alef(skip))]
pub async fn start_mcp_server_http(
host: impl AsRef<str>,
port: u16,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use axum::Router;
use std::net::SocketAddr;
let http_service = StreamableHttpService::new(
|| Ok(HtmlToMarkdownMcp::new()),
LocalSessionManager::default().into(),
Default::default(),
);
let router = Router::new().nest_service("/mcp", http_service);
let addr: SocketAddr = format!("{}:{}", host.as_ref(), port)
.parse()
.map_err(|e| format!("Invalid address: {e}"))?;
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, router).await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use rmcp::model::{ProtocolVersion, RawContent};
#[test]
fn test_tool_router_has_convert_html() {
let router = HtmlToMarkdownMcp::tool_router();
assert!(router.has_route("convert_html"), "convert_html tool must be registered");
}
#[test]
fn test_server_info_fields() {
let server = HtmlToMarkdownMcp::new();
let info = server.get_info();
assert_eq!(info.server_info.name, "html-to-markdown-mcp");
assert_eq!(info.server_info.version, env!("CARGO_PKG_VERSION"));
assert!(info.capabilities.tools.is_some());
assert!(info.instructions.is_some());
}
#[test]
fn test_server_info_has_description() {
let server = HtmlToMarkdownMcp::new();
let info = server.get_info();
assert!(info.server_info.title.is_some());
assert!(info.server_info.website_url.is_some());
}
#[test]
fn test_server_info_protocol_version() {
let server = HtmlToMarkdownMcp::new();
let info = server.get_info();
assert_eq!(info.protocol_version, ProtocolVersion::default());
}
#[tokio::test]
async fn test_convert_html_basic() {
let server = HtmlToMarkdownMcp::new();
let params = super::super::params::ConvertHtmlParams {
html: "<h1>Hello</h1>".into(),
options: None,
json: false,
};
let result = server
.convert_html(Parameters(params))
.await
.expect("conversion must succeed");
assert!(!result.content.is_empty(), "result must have content");
let text = match &result.content[0].raw {
RawContent::Text(t) => t.text.clone(),
_ => panic!("expected text content"),
};
assert!(text.contains("# Hello"), "markdown must contain heading; got: {text}");
}
#[tokio::test]
async fn test_convert_html_json_output() {
let server = HtmlToMarkdownMcp::new();
let params = super::super::params::ConvertHtmlParams {
html: "<h1>World</h1>".into(),
options: None,
json: true,
};
let result = server
.convert_html(Parameters(params))
.await
.expect("conversion must succeed");
let text = match &result.content[0].raw {
RawContent::Text(t) => t.text.clone(),
_ => panic!("expected text content"),
};
let parsed: serde_json::Value = serde_json::from_str(&text).expect("json output must be valid JSON");
assert!(parsed.get("content").is_some(), "JSON must have content field");
}
#[tokio::test]
async fn test_convert_html_with_options() {
let server = HtmlToMarkdownMcp::new();
let opts = serde_json::json!({"escape_asterisks": true});
let params = super::super::params::ConvertHtmlParams {
html: "<p>*bold*</p>".into(),
options: Some(opts),
json: false,
};
let result = server
.convert_html(Parameters(params))
.await
.expect("conversion must succeed");
assert!(!result.content.is_empty());
}
#[tokio::test]
async fn test_convert_html_invalid_options_returns_error() {
let server = HtmlToMarkdownMcp::new();
let opts = serde_json::json!({"unknown_field_xyz": true});
let params = super::super::params::ConvertHtmlParams {
html: "<p>test</p>".into(),
options: Some(opts),
json: false,
};
let result = server.convert_html(Parameters(params)).await;
assert!(result.is_err(), "unknown option field must produce an error");
let err = result.unwrap_err();
assert_eq!(err.code.0, -32602, "must be invalid_params");
}
}