1#![forbid(unsafe_code)]
23
24mod output;
25
26use std::collections::BTreeMap;
27use std::fmt;
28
29use oxml::{EmptyElement, Indent, Newline, SerialiseOptions};
30use rmcp::handler::server::router::tool::ToolRouter;
31use rmcp::handler::server::tool::{ToolCallContext, schema_for_output};
32use rmcp::handler::server::wrapper::Parameters;
33use rmcp::model::{
34 CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock,
35 ErrorData, Implementation, ServerCapabilities, ServerConfig,
36};
37use rmcp::service::RequestContext;
38use rmcp::{RoleServer, ServerHandler, tool, tool_handler, tool_router};
39use schemars::JsonSchema;
40use serde::{Deserialize, Serialize};
41
42fn parse_doc(xml: &str) -> Result<oxml::Document, String> {
47 oxml::parse_with(xml, oxml::Limits::strict()).map_err(|e| {
48 let (line, col) = e.line_column(xml);
49 format!(
50 "The document is not well-formed at line {line}, column {col}: {e}"
51 )
52 })
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
57pub struct QueryOutput {
58 pub count: usize,
60 pub values: Vec<String>,
64}
65
66impl fmt::Display for QueryOutput {
67 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71 if self.count == 0 {
72 return f.write_str("No nodes matched.");
73 }
74 if self.values.is_empty() {
75 return write!(
76 f,
77 "{} node(s) matched, all with empty text.",
78 self.count
79 );
80 }
81 f.write_str(&self.values.join("\n"))
82 }
83}
84
85pub fn query(
97 xml: &str,
98 xpath: &str,
99 namespaces: &[(&str, &str)],
100) -> Result<QueryOutput, String> {
101 let doc = parse_doc(xml)?;
102 let bindings: Vec<(&str, &str)> = namespaces
103 .iter()
104 .copied()
105 .filter(|(prefix, _)| *prefix != "xml")
106 .collect();
107 let compiled = oxml::XPath::compile_with_namespaces_and_limits(
112 xpath,
113 &bindings,
114 &oxml::Limits::strict(),
115 )
116 .map_err(|error| explain_xpath_error(xpath, &error))?;
117 let context = oxml::xpath::EvalContext::new()
118 .with_limits(oxml::xpath::EvalLimits::default());
119 let value = compiled
120 .evaluate_with(&doc, &context)
121 .map_err(|e| e.to_string())?;
122
123 let Some(nodes) = value.nodes() else {
124 return Ok(QueryOutput {
125 count: 1,
126 values: vec![value.to_str(&doc)],
127 });
128 };
129 let mut remaining = oxml::xpath::EvalLimits::default().max_string_bytes;
130 let values: Vec<String> = nodes
131 .iter()
132 .map(|n| output::text(&doc, *n, &mut remaining))
133 .collect::<Result<Vec<_>, _>>()?
134 .into_iter()
135 .filter(|t| !t.trim().is_empty())
136 .collect();
137 Ok(QueryOutput {
138 count: nodes.len(),
139 values,
140 })
141}
142
143fn explain_xpath_error(xpath: &str, error: &oxml::XPathError) -> String {
144 if error.message.contains("unbound namespace prefix") {
147 let prefix = error
148 .message
149 .split('`')
150 .nth(1)
151 .unwrap_or("PREFIX")
152 .to_owned();
153 format!(
154 "The XPath expression uses the namespace prefix \
155 `{prefix}`, which is not bound. Pass it in the \
156 `namespaces` argument, for example \
157 {{\"{prefix}\": \"urn:example\"}}. Call `xml_inspect` \
158 to see which namespaces the document uses."
159 )
160 } else {
161 let (line, column) = xpath_line_column(xpath, error.offset);
162 format!(
163 "The XPath expression is invalid at line {line}, column {column}: {}",
164 error.message
165 )
166 }
167}
168
169fn xpath_line_column(input: &str, offset: usize) -> (usize, usize) {
170 let mut end = offset.min(input.len());
171 while end > 0 && !input.is_char_boundary(end) {
172 end -= 1;
173 }
174 let upto = &input[..end];
175 let line = upto.matches('\n').count() + 1;
176 let column = upto
177 .rsplit('\n')
178 .next()
179 .map_or(1, |line| line.chars().count() + 1);
180 (line, column)
181}
182
183#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
185pub struct Violation {
186 pub path: String,
188 pub message: String,
190}
191
192#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
194pub struct ValidateOutput {
195 pub valid: bool,
197 pub violations: Vec<Violation>,
199}
200
201impl fmt::Display for ValidateOutput {
202 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
203 if self.valid {
204 return f.write_str("The document is valid against the schema.");
205 }
206 writeln!(f, "{} violation(s):", self.violations.len())?;
207 for v in &self.violations {
208 writeln!(f, " {} — {}", v.path, v.message)?;
209 }
210 Ok(())
211 }
212}
213
214pub fn validate(xml: &str, xsd: &str) -> Result<ValidateOutput, String> {
224 if xsd.len() > 8 * 1024 * 1024 {
225 return Err("The schema exceeds the 8 MiB input limit".to_owned());
226 }
227 let schema = xmlschema::parse_schema(xsd)
228 .map_err(|e| format!("The schema could not be read: {e}"))?;
229 let doc = parse_doc(xml)?;
230 let report = xmlschema::validate(&doc, &schema);
231 Ok(ValidateOutput {
232 valid: report.is_valid(),
233 violations: report
234 .violations
235 .iter()
236 .map(|v| Violation {
237 path: v.path.clone(),
238 message: v.message.clone(),
239 })
240 .collect(),
241 })
242}
243
244#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
246pub struct CheckOutput {
247 pub well_formed: bool,
250 pub nodes: usize,
252}
253
254impl fmt::Display for CheckOutput {
255 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
256 write!(f, "The document is well-formed ({} nodes).", self.nodes)
257 }
258}
259
260pub fn check(xml: &str) -> Result<CheckOutput, String> {
266 let doc = parse_doc(xml)?;
267 Ok(CheckOutput {
268 well_formed: true,
269 nodes: doc.len(),
270 })
271}
272
273#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
275pub struct InspectOutput {
276 pub root: String,
278 pub max_depth: usize,
280 pub elements: BTreeMap<String, usize>,
282 pub namespaces: BTreeMap<String, usize>,
284}
285
286impl fmt::Display for InspectOutput {
287 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
288 writeln!(f, "Root element: {}", self.root)?;
289 writeln!(f, "Maximum depth: {}", self.max_depth)?;
290 writeln!(f, "Elements:")?;
291 for (name, n) in &self.elements {
292 writeln!(f, " {name}: {n}")?;
293 }
294 if self.namespaces.is_empty() {
299 writeln!(f, "Namespaces: none")
300 } else {
301 writeln!(
302 f,
303 "Namespaces (pass these to xml_query as `namespaces`):"
304 )?;
305 for (uri, n) in &self.namespaces {
306 writeln!(f, " {uri}: {n} element(s)")?;
307 }
308 Ok(())
309 }
310 }
311}
312
313pub fn inspect(xml: &str) -> Result<InspectOutput, String> {
319 let doc = parse_doc(xml)?;
320 let mut elements: BTreeMap<String, usize> = BTreeMap::new();
321 let mut namespaces: BTreeMap<String, usize> = BTreeMap::new();
322 let mut max_depth = 0usize;
323
324 for id in doc.descendants() {
325 if let Some(name) = doc.element_name(id) {
326 *elements.entry(name.local.clone()).or_default() += 1;
327 if let Some(uri) = &name.namespace {
328 *namespaces.entry(uri.clone()).or_default() += 1;
329 }
330 let mut d = 0usize;
331 let mut cur = Some(id);
332 while let Some(n) = cur {
333 cur = doc.parent(n);
334 d += 1;
335 }
336 max_depth = max_depth.max(d);
337 }
338 }
339
340 let root = doc
341 .root_element()
342 .and_then(|r| doc.element_name(r))
343 .map_or_else(|| "none".to_owned(), |n| n.local.clone());
344
345 Ok(InspectOutput {
346 root,
347 max_depth,
348 elements,
349 namespaces,
350 })
351}
352
353#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
355pub struct FormatOutput {
356 pub original_size: usize,
358 pub formatted_size: usize,
360 pub xml: String,
362}
363
364impl fmt::Display for FormatOutput {
365 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
366 write!(
367 f,
368 "Formatted document ({} bytes -> {} bytes):\n{}",
369 self.original_size, self.formatted_size, self.xml
370 )
371 }
372}
373
374pub fn format_xml(
381 xml: &str,
382 indent: Option<u8>,
383 empty_elements: Option<&str>,
384) -> Result<FormatOutput, String> {
385 let doc = parse_doc(xml)?;
386 let options = SerialiseOptions {
387 indent: indent
388 .and_then(|n| if n > 0 { Some(Indent::Spaces(n)) } else { None }),
389 empty_elements: match empty_elements {
390 Some("expanded") => EmptyElement::Expanded,
391 Some("spaced" | "self-closing-spaced") => {
392 EmptyElement::SelfClosingSpaced
393 }
394 _ => EmptyElement::SelfClosing,
395 },
396 newline: Newline::Lf,
397 };
398 let formatted = doc.to_xml_with(options);
399 Ok(FormatOutput {
400 original_size: xml.len(),
401 formatted_size: formatted.len(),
402 xml: formatted,
403 })
404}
405
406#[allow(clippy::doc_markdown, reason = "tool descriptions, shown verbatim")]
414#[derive(Debug, Deserialize, JsonSchema)]
415pub struct QueryArgs {
416 #[schemars(example = &"<library><book lang=\"en\"><title>Dune</title></book></library>")]
418 pub xml: String,
419 #[schemars(example = &"//book/title")]
421 pub xpath: String,
422 #[serde(default)]
427 pub namespaces: BTreeMap<String, String>,
428}
429
430#[derive(Debug, Deserialize, JsonSchema)]
432pub struct ValidateArgs {
433 #[schemars(example = &"<library><book lang=\"en\"><title>Dune</title></book></library>")]
435 pub xml: String,
436 #[schemars(example = &"<xs:schema xmlns:xs=\"http://www.w3.org/2001/XMLSchema\"><xs:element name=\"library\"/></xs:schema>")]
438 pub xsd: String,
439}
440
441#[derive(Debug, Deserialize, JsonSchema)]
443pub struct DocumentArgs {
444 #[schemars(example = &"<library><book lang=\"en\"><title>Dune</title></book></library>")]
446 pub xml: String,
447}
448
449#[derive(Debug, Deserialize, JsonSchema)]
451pub struct FormatArgs {
452 #[schemars(example = &"<library><book lang=\"en\"><title>Dune</title></book></library>")]
454 pub xml: String,
455 #[serde(default)]
458 pub indent: Option<u8>,
459 #[serde(default)]
462 pub empty_elements: Option<String>,
463}
464
465fn reply<T: Serialize + fmt::Display>(
471 outcome: Result<T, String>,
472 is_error: impl FnOnce(&T) -> bool,
473) -> Result<CallToolResult, ErrorData> {
474 match outcome {
475 Ok(value) => {
476 let structured = serde_json::to_value(&value)
477 .map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
478 let content = vec![ContentBlock::text(value.to_string())];
479 let mut result = if is_error(&value) {
480 CallToolResult::error(content)
481 } else {
482 CallToolResult::success(content)
483 };
484 result.structured_content = Some(structured);
485 Ok(result)
486 }
487 Err(message) => {
492 Ok(CallToolResult::error(vec![ContentBlock::text(message)]))
493 }
494 }
495}
496
497#[derive(Debug, Clone)]
502pub struct XmlServer {
503 tool_router: ToolRouter<Self>,
504}
505
506impl Default for XmlServer {
507 fn default() -> Self {
508 Self::new()
509 }
510}
511
512#[tool_router]
513#[allow(
514 clippy::unused_self,
515 reason = "the SDK's tool router calls tools as methods"
516)]
517impl XmlServer {
518 #[must_use]
520 pub fn new() -> Self {
521 Self {
522 tool_router: Self::tool_router(),
523 }
524 }
525
526 #[tool(
527 name = "xml_query",
528 description = "Evaluate an XPath 1.0 expression against an XML \
529 document and return the matching values. Use this \
530 instead of reading a large document into context.",
531 annotations(
532 title = "Query XML with XPath",
533 read_only_hint = true,
534 destructive_hint = false,
535 idempotent_hint = true,
536 open_world_hint = false
537 ),
538 output_schema = schema_for_output::<QueryOutput>()
539 )]
540 fn xml_query(
541 &self,
542 Parameters(args): Parameters<QueryArgs>,
543 ) -> Result<CallToolResult, ErrorData> {
544 let namespaces: Vec<(&str, &str)> = args
545 .namespaces
546 .iter()
547 .map(|(p, u)| (p.as_str(), u.as_str()))
548 .collect();
549 reply(query(&args.xml, &args.xpath, &namespaces), |_| false)
550 }
551
552 #[tool(
553 name = "xml_validate",
554 description = "Validate an XML document against an XML Schema \
555 (XSD). Returns every violation with the path to \
556 the element it concerns.",
557 annotations(
558 title = "Validate XML against an XSD",
559 read_only_hint = true,
560 destructive_hint = false,
561 idempotent_hint = true,
562 open_world_hint = false
563 ),
564 output_schema = schema_for_output::<ValidateOutput>()
565 )]
566 fn xml_validate(
567 &self,
568 Parameters(args): Parameters<ValidateArgs>,
569 ) -> Result<CallToolResult, ErrorData> {
570 reply(validate(&args.xml, &args.xsd), |v| !v.valid)
574 }
575
576 #[tool(
577 name = "xml_check",
578 description = "Check whether a document is well-formed, and \
579 report the line and column if it is not.",
580 annotations(
581 title = "Check XML well-formedness",
582 read_only_hint = true,
583 destructive_hint = false,
584 idempotent_hint = true,
585 open_world_hint = false
586 ),
587 output_schema = schema_for_output::<CheckOutput>()
588 )]
589 fn xml_check(
590 &self,
591 Parameters(args): Parameters<DocumentArgs>,
592 ) -> Result<CallToolResult, ErrorData> {
593 reply(check(&args.xml), |_| false)
594 }
595
596 #[tool(
597 name = "xml_inspect",
598 description = "Summarise a document's structure: element counts, \
599 depth, the element names present, and the \
600 namespaces it uses. Use this to understand a \
601 document's shape before querying it.",
602 annotations(
603 title = "Inspect XML structure",
604 read_only_hint = true,
605 destructive_hint = false,
606 idempotent_hint = true,
607 open_world_hint = false
608 ),
609 output_schema = schema_for_output::<InspectOutput>()
610 )]
611 fn xml_inspect(
612 &self,
613 Parameters(args): Parameters<DocumentArgs>,
614 ) -> Result<CallToolResult, ErrorData> {
615 reply(inspect(&args.xml), |_| false)
616 }
617
618 #[tool(
619 name = "xml_format",
620 description = "Format an XML document with configurable indentation \
621 (spaces) and empty element style, or minify it. \
622 Use this to pretty-print or compact XML.",
623 annotations(
624 title = "Format or minify XML",
625 read_only_hint = true,
626 destructive_hint = false,
627 idempotent_hint = true,
628 open_world_hint = false
629 ),
630 output_schema = schema_for_output::<FormatOutput>()
631 )]
632 fn xml_format(
633 &self,
634 Parameters(args): Parameters<FormatArgs>,
635 ) -> Result<CallToolResult, ErrorData> {
636 reply(
637 format_xml(&args.xml, args.indent, args.empty_elements.as_deref()),
638 |_| false,
639 )
640 }
641}
642
643#[tool_handler(router = self.tool_router)]
644#[allow(
645 clippy::unused_async_trait_impl,
646 reason = "the SDK's handler macro generates the trait methods"
647)]
648impl ServerHandler for XmlServer {
649 fn get_info(&self) -> ServerConfig {
650 ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
651 .with_server_info(
652 Implementation::new("oxml-mcp", env!("CARGO_PKG_VERSION"))
653 .with_title("oxml MCP")
654 .with_website_url(env!("CARGO_PKG_REPOSITORY")),
655 )
656 .with_instructions(
657 "XML tools. Documents are passed as strings, never as \
658 paths. xml_inspect reports a document's element names \
659 and namespaces; xml_query evaluates XPath 1.0 against \
660 it; xml_check reports well-formedness; xml_validate \
661 checks it against an XSD; xml_format formats or \
662 minifies it.",
663 )
664 }
665
666 async fn call_tool(
674 &self,
675 request: CallToolRequestParams,
676 context: RequestContext<RoleServer>,
677 ) -> Result<CallToolResponse, ErrorData> {
678 if !self.tool_router.has_route(&request.name) {
679 return Ok(CallToolResult::error(vec![ContentBlock::text(
680 format!(
681 "Unknown tool: {}. The tools are xml_query, xml_validate, \
682 xml_check, xml_inspect and xml_format.",
683 request.name
684 ),
685 )])
686 .into());
687 }
688 let call = ToolCallContext::new(self, request, context);
689 self.tool_router.call(call).await
690 }
691}
692
693#[cfg(test)]
694mod tests {
695 use super::*;
696 use serde_json::{Value, json};
697
698 const DOC: &str = "<library><book lang=\"en\"><title>Dune</title>\
699 </book><book lang=\"fr\"><title>Germinal</title>\
700 </book></library>";
701
702 fn parse<T: serde::de::DeserializeOwned>(v: Value) -> T {
705 serde_json::from_value(v).expect("arguments")
706 }
707
708 fn call(tool: &str, args: Value) -> CallToolResult {
709 let server = XmlServer::new();
710 let result = match tool {
711 "xml_query" => server.xml_query(Parameters(parse(args))),
712 "xml_validate" => server.xml_validate(Parameters(parse(args))),
713 "xml_check" => server.xml_check(Parameters(parse(args))),
714 "xml_inspect" => server.xml_inspect(Parameters(parse(args))),
715 "xml_format" => server.xml_format(Parameters(parse(args))),
716 other => panic!("no such tool {other}"),
717 };
718 result.expect("a tool failure is a result, not a protocol error")
719 }
720
721 fn text_of(r: &CallToolResult) -> &str {
722 r.content
723 .first()
724 .and_then(ContentBlock::as_text)
725 .map(|t| t.text.as_str())
726 .expect("text content")
727 }
728
729 fn is_error(r: &CallToolResult) -> bool {
730 r.is_error == Some(true)
731 }
732
733 #[test]
734 fn every_tool_is_registered_with_schema_and_annotations() {
735 let tools = XmlServer::tool_router().list_all();
736 let mut names: Vec<&str> =
737 tools.iter().map(|t| t.name.as_ref()).collect();
738 names.sort_unstable();
739 assert_eq!(
740 names,
741 [
742 "xml_check",
743 "xml_format",
744 "xml_inspect",
745 "xml_query",
746 "xml_validate"
747 ]
748 );
749 for t in &tools {
750 assert!(t.description.is_some(), "{} has no description", t.name);
751 assert_eq!(
752 t.input_schema.get("type").and_then(Value::as_str),
753 Some("object")
754 );
755 assert!(t.input_schema.get("properties").is_some());
756 assert!(
757 t.output_schema.is_some(),
758 "{} has no outputSchema",
759 t.name
760 );
761 let a = t.annotations.as_ref().expect("annotations");
762 assert_eq!(a.read_only_hint, Some(true), "{}", t.name);
763 }
764 }
765
766 #[test]
767 fn input_schemas_keep_the_field_names_and_descriptions() {
768 let tools = XmlServer::tool_router().list_all();
769 let query =
770 tools.iter().find(|t| t.name == "xml_query").expect("query");
771 let props = query.input_schema.get("properties").expect("properties");
772 assert_eq!(
773 props["xml"]["description"].as_str(),
774 Some("The XML document")
775 );
776 assert_eq!(
777 props["xpath"]["description"].as_str(),
778 Some("An XPath 1.0 expression")
779 );
780 assert!(
781 props["namespaces"]["description"]
782 .as_str()
783 .is_some_and(|d| d.contains("xml_inspect")),
784 "{props}"
785 );
786 assert_eq!(props["namespaces"]["type"].as_str(), Some("object"));
787 let required =
788 query.input_schema["required"].as_array().expect("required");
789 assert_eq!(required, &[json!("xml"), json!("xpath")]);
790 }
791
792 #[test]
793 fn xml_query_returns_the_selected_text() {
794 let r =
795 call("xml_query", json!({"xml": DOC, "xpath": "//book[1]/title"}));
796 assert!(!is_error(&r));
797 assert!(text_of(&r).contains("Dune"), "{}", text_of(&r));
798 assert_eq!(
799 r.structured_content,
800 Some(json!({"count": 1, "values": ["Dune"]}))
801 );
802 }
803
804 #[test]
805 fn xml_query_reads_attributes() {
806 let r =
807 call("xml_query", json!({"xml": DOC, "xpath": "//book[2]/@lang"}));
808 assert!(!is_error(&r));
809 assert!(text_of(&r).contains("fr"), "{}", text_of(&r));
810 }
811
812 #[test]
813 fn xml_check_accepts_and_rejects() {
814 let good = call("xml_check", json!({"xml": DOC}));
815 assert!(!is_error(&good));
816 assert_eq!(
817 good.structured_content,
818 Some(json!({"well_formed": true, "nodes": 11}))
819 );
820
821 let bad = call("xml_check", json!({"xml": "<a><b></a>"}));
822 assert!(is_error(&bad));
823 assert!(
825 text_of(&bad).contains("line 1, column"),
826 "{}",
827 text_of(&bad)
828 );
829 assert!(bad.structured_content.is_none());
830 }
831
832 #[test]
833 fn xml_inspect_summarises_the_document() {
834 let r = call("xml_inspect", json!({"xml": DOC}));
835 let text = text_of(&r);
836 assert!(text.contains("Root element: library"), "{text}");
837 assert!(text.contains("book: 2"), "{text}");
838 let s = r.structured_content.expect("structured");
839 assert_eq!(s["root"], "library");
840 assert_eq!(s["max_depth"], 4);
841 assert_eq!(s["elements"]["book"], 2);
842 }
843
844 #[test]
845 fn xml_validate_reports_both_outcomes() {
846 let xsd = r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
847 <xs:element name="note" type="xs:string"/>
848 </xs:schema>"#;
849 let ok = call(
850 "xml_validate",
851 json!({"xml": "<note>hi</note>", "xsd": xsd}),
852 );
853 assert!(!is_error(&ok), "{ok:?}");
854 assert_eq!(
855 ok.structured_content,
856 Some(json!({"valid": true, "violations": []}))
857 );
858
859 let bad = call(
860 "xml_validate",
861 json!({"xml": "<wrong>hi</wrong>", "xsd": xsd}),
862 );
863 assert!(is_error(&bad), "{bad:?}");
864 assert!(text_of(&bad).contains("violation(s)"), "{}", text_of(&bad));
865 let s = bad.structured_content.expect("structured");
867 assert_eq!(s["valid"], false);
868 assert!(!s["violations"].as_array().expect("array").is_empty());
869
870 let unreadable =
871 call("xml_validate", json!({"xml": "<a/>", "xsd": "<"}));
872 assert!(is_error(&unreadable));
873 assert!(text_of(&unreadable).contains("schema could not be read"));
874 }
875
876 #[test]
877 fn a_tool_failure_is_a_result_not_a_protocol_error() {
878 let r = XmlServer::new().xml_check(Parameters(DocumentArgs {
882 xml: "<a>".to_owned(),
883 }));
884 let r = r.expect("Ok, not Err");
885 assert!(is_error(&r));
886 }
887
888 #[test]
889 fn a_bound_prefix_selects_only_that_namespace() {
890 let xml =
893 r#"<r xmlns:m="urn:u"><m:item>ns</m:item><item>plain</item></r>"#;
894 let r = call(
895 "xml_query",
896 json!({"xml": xml, "xpath": "//m:item", "namespaces": {"m": "urn:u"}}),
897 );
898 assert!(!is_error(&r), "{r:?}");
899 assert_eq!(text_of(&r), "ns");
900 }
901
902 #[test]
903 fn an_unbound_prefix_says_what_to_do_about_it() {
904 let xml = r#"<r xmlns:m="urn:u"><m:item>ns</m:item></r>"#;
908 let r = call("xml_query", json!({"xml": xml, "xpath": "//m:item"}));
909 assert!(is_error(&r));
910 let text = text_of(&r);
911 assert!(text.contains("namespaces"), "{text}");
912 assert!(text.contains("xml_inspect"), "{text}");
913 }
914
915 #[test]
916 fn inspect_reports_the_namespaces_a_document_uses() {
917 let xml = r#"<r xmlns:m="urn:u"><m:item>ns</m:item></r>"#;
918 let r = call("xml_inspect", json!({"xml": xml}));
919 assert!(text_of(&r).contains("urn:u"), "{}", text_of(&r));
920 assert_eq!(
921 r.structured_content.expect("structured")["namespaces"]["urn:u"],
922 1
923 );
924
925 let plain = call("xml_inspect", json!({"xml": "<r><item/></r>"}));
926 assert!(
927 text_of(&plain).contains("Namespaces: none"),
928 "{}",
929 text_of(&plain)
930 );
931 }
932
933 #[test]
934 fn the_xml_prefix_may_not_be_rebound() {
935 let xml = r#"<r><a xml:lang="en">x</a></r>"#;
938 let r = call(
939 "xml_query",
940 json!({"xml": xml, "xpath": "//@xml:lang", "namespaces": {"xml": "urn:wrong"}}),
941 );
942 assert!(!is_error(&r), "{r:?}");
943 assert_eq!(text_of(&r), "en");
944 }
945
946 #[test]
947 fn a_scalar_expression_returns_its_value() {
948 let r =
951 call("xml_query", json!({"xml": DOC, "xpath": "count(//book)"}));
952 assert!(!is_error(&r));
953 assert_eq!(text_of(&r).trim(), "2");
954 assert_eq!(
955 r.structured_content,
956 Some(json!({"count": 1, "values": ["2"]}))
957 );
958 }
959
960 #[test]
961 fn a_query_matching_nothing_says_so() {
962 let r =
963 call("xml_query", json!({"xml": DOC, "xpath": "//nonexistent"}));
964 assert!(!is_error(&r));
965 assert!(text_of(&r).contains("No nodes matched"), "{}", text_of(&r));
966 assert_eq!(
967 r.structured_content,
968 Some(json!({"count": 0, "values": []}))
969 );
970 }
971
972 #[test]
973 fn matches_with_no_text_report_the_count_instead() {
974 let r = call(
977 "xml_query",
978 json!({"xml": "<r><e/><e/></r>", "xpath": "//e"}),
979 );
980 assert!(!is_error(&r));
981 let text = text_of(&r);
982 assert!(text.contains('2'), "{text}");
983 assert!(text.contains("empty text"), "{text}");
984 assert_eq!(
985 r.structured_content,
986 Some(json!({"count": 2, "values": []}))
987 );
988 }
989
990 #[test]
991 fn an_invalid_xpath_is_reported_as_such() {
992 let r = call("xml_query", json!({"xml": DOC, "xpath": "//["}));
993 assert!(is_error(&r));
994 assert!(
995 text_of(&r)
996 .contains("XPath expression is invalid at line 1, column 3"),
997 "{}",
998 text_of(&r)
999 );
1000 }
1001
1002 #[test]
1003 fn an_invalid_multiline_xpath_reports_expression_line_and_column() {
1004 let r = call(
1005 "xml_query",
1006 json!({"xml": DOC, "xpath": "//book[\n@lang = ]"}),
1007 );
1008 assert!(is_error(&r));
1009 assert!(
1010 text_of(&r)
1011 .contains("XPath expression is invalid at line 2, column 9"),
1012 "{}",
1013 text_of(&r)
1014 );
1015 }
1016
1017 #[test]
1018 fn xpath_positions_stop_at_character_boundaries() {
1019 assert_eq!(xpath_line_column("é", 1), (1, 1));
1022 assert_eq!(xpath_line_column("ab", 9), (1, 3));
1023 }
1024
1025 #[test]
1026 fn a_control_character_is_rejected_with_a_location() {
1027 let r = call("xml_check", json!({"xml": "<a>\u{1}</a>"}));
1030 assert!(is_error(&r));
1031 assert!(text_of(&r).contains("line 1"), "{}", text_of(&r));
1032 let r = call("xml_check", json!({"xml": "<a>\t</a>"}));
1034 assert!(!is_error(&r), "{r:?}");
1035 }
1036
1037 #[test]
1038 fn the_server_describes_itself() {
1039 let info = XmlServer::default().get_info();
1040 assert_eq!(info.server_info.name, "oxml-mcp");
1041 assert_eq!(info.server_info.version, env!("CARGO_PKG_VERSION"));
1042 assert!(info.capabilities.tools.is_some());
1043 assert!(info.instructions.is_some());
1044 }
1045
1046 #[test]
1047 fn xml_format_pretty_prints_and_minifies() {
1048 let unformatted = "<root><child id=\"1\"><sub/></child></root>";
1049 let r = call(
1050 "xml_format",
1051 json!({"xml": unformatted, "indent": 2, "empty_elements": "self-closing"}),
1052 );
1053 assert!(!is_error(&r), "{r:?}");
1054 let structured = r.structured_content.expect("structured");
1055 let formatted = structured["xml"].as_str().expect("xml string");
1056 assert!(formatted.contains("\n <child"), "{formatted}");
1057 assert!(formatted.contains("<sub/>"), "{formatted}");
1058
1059 let r_mini =
1061 call("xml_format", json!({"xml": unformatted, "indent": 0}));
1062 assert!(!is_error(&r_mini));
1063 let mini = r_mini.structured_content.expect("structured")["xml"]
1064 .as_str()
1065 .expect("xml string")
1066 .to_owned();
1067 assert_eq!(mini, unformatted);
1068
1069 let r_exp = call(
1071 "xml_format",
1072 json!({"xml": "<a/>", "empty_elements": "expanded"}),
1073 );
1074 assert!(!is_error(&r_exp));
1075 assert_eq!(
1076 r_exp.structured_content.expect("structured")["xml"].as_str(),
1077 Some("<a></a>")
1078 );
1079
1080 let r_err = call("xml_format", json!({"xml": "<root><unclosed>"}));
1082 assert!(is_error(&r_err));
1083 assert!(text_of(&r_err).contains("not well-formed"));
1084 }
1085}