use markitdown_core::fetch::{self, SourceError};
use markitdown_core::{Error, Options};
use serde_json::{Value, json};
use std::io::{BufRead, Write};
const TOOL: &str = "convert_to_markdown";
const PROTOCOL_VERSIONS: [&str; 4] = ["2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25"];
fn tool_list() -> Value {
json!({"tools": [{
"name": TOOL,
"description": "Convert a resource described by an http:, https:, file: or data: URI to markdown",
"inputSchema": {
"properties": {"uri": {"title": "Uri", "type": "string"}},
"required": ["uri"],
"type": "object",
"title": "convert_to_markdownArguments"
},
"outputSchema": {
"properties": {"result": {"title": "Result", "type": "string"}},
"required": ["result"],
"type": "object",
"title": "convert_to_markdownOutput"
}
}]})
}
fn strerror(e: &std::io::Error) -> String {
match e.raw_os_error() {
Some(code) => {
let text = std::io::Error::from_raw_os_error(code).to_string();
match text.rfind(" (os error ") {
Some(i) => text[..i].to_string(),
None => text,
}
}
None => "the resource could not be read".into(),
}
}
fn tool_body(uri: &str) -> Result<String, String> {
let (data, info) = fetch::load(uri).map_err(|e| match e {
SourceError::Invalid(m) => m,
SourceError::Http { status, .. } => {
format!("Fetching the resource failed with HTTP status {status}.")
}
SourceError::Request { .. } => "Could not fetch the resource.".into(),
SourceError::Io { error, .. } => {
format!("Could not read the resource: {}.", strerror(&error))
}
})?;
markitdown_core::convert(&data, &info, &Options::default()).map_err(|e| match e {
Error::Unsupported(_) => "Could not convert stream to Markdown. No converter attempted a \
conversion, suggesting that the filetype is simply not supported."
.into(),
_ => "File conversion failed.".into(),
})
}
fn tool_text(text: String, is_error: bool) -> Value {
let mut r = json!({"content": [{"type": "text", "text": text}], "isError": is_error});
if !is_error {
r["structuredContent"] = json!({ "result": r["content"][0]["text"] });
}
r
}
fn call_tool(params: &Value) -> Value {
let name = params["name"].as_str().unwrap_or("");
if name != TOOL {
return tool_text(format!("Unknown tool: {name}"), true);
}
let fail = |m: String| tool_text(format!("Error executing tool {TOOL}: {m}"), true);
let validation = |what: &str| {
fail(format!(
"1 validation error for convert_to_markdownArguments\nuri\n {what}"
))
};
match params["arguments"].get("uri") {
None => validation("Field required"),
Some(Value::String(uri)) => match tool_body(uri) {
Ok(md) => tool_text(md, false),
Err(m) => fail(m),
},
Some(_) => validation("Input should be a valid string"),
}
}
fn handle(msg: &Value) -> Option<Value> {
let id = msg.get("id")?.clone();
let method = msg["method"].as_str().unwrap_or("");
let result = match method {
"initialize" => {
let asked = msg["params"]["protocolVersion"].as_str().unwrap_or("");
let version = PROTOCOL_VERSIONS
.into_iter()
.find(|v| *v == asked)
.unwrap_or(PROTOCOL_VERSIONS[3]);
json!({
"capabilities": {
"prompts": {"listChanged": false},
"resources": {"listChanged": false, "subscribe": false},
"tools": {"listChanged": false}
},
"protocolVersion": version,
"serverInfo": {"name": "markitdown", "version": env!("CARGO_PKG_VERSION")}
})
}
"ping" => json!({}),
"tools/list" => tool_list(),
"tools/call" => call_tool(&msg["params"]),
"prompts/list" => json!({"prompts": []}),
"resources/list" => json!({"resources": []}),
"resources/templates/list" => json!({"resourceTemplates": []}),
_ => {
return Some(json!({
"jsonrpc": "2.0",
"id": id,
"error": {"code": -32601, "message": "Method not found", "data": method}
}));
}
};
Some(json!({"jsonrpc": "2.0", "id": id, "result": result}))
}
pub fn serve() -> Result<(), String> {
eprintln!(
"markitdown-rs {} MCP server on stdio",
env!("CARGO_PKG_VERSION")
);
let stdin = std::io::stdin();
let mut out = std::io::stdout().lock();
for line in stdin.lock().lines() {
let line = line.map_err(|e| e.to_string())?;
if line.trim().is_empty() {
continue;
}
let reply = match serde_json::from_str::<Value>(&line) {
Ok(msg) => handle(&msg),
Err(e) => Some(json!({
"jsonrpc": "2.0",
"id": null,
"error": {"code": -32700, "message": format!("Parse error: {e}")}
})),
};
if let Some(reply) = reply {
writeln!(out, "{reply}")
.and_then(|_| out.flush())
.map_err(|e| e.to_string())?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn errors_read_like_markitdown_mcp() {
let e = std::io::Error::from_raw_os_error(2);
if cfg!(unix) {
assert_eq!(strerror(&e), "No such file or directory");
}
assert!(!strerror(&e).contains("os error"));
let r = call_tool(&json!({"name": "nope", "arguments": {}}));
assert_eq!(r["content"][0]["text"], "Unknown tool: nope");
assert_eq!(r["isError"], true);
assert!(
handle(&json!({"jsonrpc": "2.0", "method": "notifications/initialized"})).is_none()
);
}
}