1#![forbid(unsafe_code)]
23
24use std::collections::BTreeMap;
25use std::fmt;
26
27use rmcp::handler::server::router::tool::ToolRouter;
28use rmcp::handler::server::tool::{ToolCallContext, schema_for_output};
29use rmcp::handler::server::wrapper::Parameters;
30use rmcp::model::{
31 CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock,
32 ErrorData, Implementation, ServerCapabilities, ServerConfig,
33};
34use rmcp::service::RequestContext;
35use rmcp::{RoleServer, ServerHandler, tool, tool_handler, tool_router};
36use schemars::JsonSchema;
37use serde::{Deserialize, Serialize};
38
39fn parse_doc(xml: &str) -> Result<oxml::Document, String> {
44 oxml::parse(xml).map_err(|e| {
45 let (line, col) = e.line_column(xml);
46 format!(
47 "The document is not well-formed at line {line}, column {col}: {e}"
48 )
49 })
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
54pub struct QueryOutput {
55 pub count: usize,
57 pub values: Vec<String>,
61}
62
63impl fmt::Display for QueryOutput {
64 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68 if self.count == 0 {
69 return f.write_str("No nodes matched.");
70 }
71 if self.values.is_empty() {
72 return write!(
73 f,
74 "{} node(s) matched, all with empty text.",
75 self.count
76 );
77 }
78 f.write_str(&self.values.join("\n"))
79 }
80}
81
82pub fn query(
94 xml: &str,
95 xpath: &str,
96 namespaces: &[(&str, &str)],
97) -> Result<QueryOutput, String> {
98 let doc = parse_doc(xml)?;
99 let bindings: Vec<(&str, &str)> = namespaces
100 .iter()
101 .copied()
102 .filter(|(prefix, _)| *prefix != "xml")
103 .collect();
104 let compiled = oxml::XPath::compile_with_namespaces(xpath, &bindings)
105 .map_err(|e| {
106 if e.message.contains("unbound namespace prefix") {
109 let prefix =
110 e.message.split('`').nth(1).unwrap_or("PREFIX").to_owned();
111 format!(
112 "The XPath expression uses the namespace prefix \
113 `{prefix}`, which is not bound. Pass it in the \
114 `namespaces` argument, for example \
115 {{\"{prefix}\": \"urn:example\"}}. Call `xml_inspect` \
116 to see which namespaces the document uses."
117 )
118 } else {
119 let (line, column) = xpath_line_column(xpath, e.offset);
120 format!(
121 "The XPath expression is invalid at line {line}, column {column}: {}",
122 e.message
123 )
124 }
125 })?;
126 let value = compiled.evaluate(&doc);
127
128 let Some(nodes) = value.nodes() else {
129 return Ok(QueryOutput {
130 count: 1,
131 values: vec![value.to_str(&doc)],
132 });
133 };
134 let values: Vec<String> = nodes
135 .iter()
136 .map(|n| doc.text(*n))
137 .filter(|t| !t.trim().is_empty())
138 .collect();
139 Ok(QueryOutput {
140 count: nodes.len(),
141 values,
142 })
143}
144
145fn xpath_line_column(input: &str, offset: usize) -> (usize, usize) {
146 let mut end = offset.min(input.len());
147 while end > 0 && !input.is_char_boundary(end) {
148 end -= 1;
149 }
150 let upto = &input[..end];
151 let line = upto.matches('\n').count() + 1;
152 let column = upto
153 .rsplit('\n')
154 .next()
155 .map_or(1, |line| line.chars().count() + 1);
156 (line, column)
157}
158
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
161pub struct Violation {
162 pub path: String,
164 pub message: String,
166}
167
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
170pub struct ValidateOutput {
171 pub valid: bool,
173 pub violations: Vec<Violation>,
175}
176
177impl fmt::Display for ValidateOutput {
178 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
179 if self.valid {
180 return f.write_str("The document is valid against the schema.");
181 }
182 writeln!(f, "{} violation(s):", self.violations.len())?;
183 for v in &self.violations {
184 writeln!(f, " {} — {}", v.path, v.message)?;
185 }
186 Ok(())
187 }
188}
189
190pub fn validate(xml: &str, xsd: &str) -> Result<ValidateOutput, String> {
200 let schema = xmlschema::parse_schema(xsd)
201 .map_err(|e| format!("The schema could not be read: {e}"))?;
202 let doc = parse_doc(xml)?;
203 let report = xmlschema::validate(&doc, &schema);
204 Ok(ValidateOutput {
205 valid: report.is_valid(),
206 violations: report
207 .violations
208 .iter()
209 .map(|v| Violation {
210 path: v.path.clone(),
211 message: v.message.clone(),
212 })
213 .collect(),
214 })
215}
216
217#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
219pub struct CheckOutput {
220 pub well_formed: bool,
223 pub nodes: usize,
225}
226
227impl fmt::Display for CheckOutput {
228 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
229 write!(f, "The document is well-formed ({} nodes).", self.nodes)
230 }
231}
232
233pub fn check(xml: &str) -> Result<CheckOutput, String> {
239 let doc = parse_doc(xml)?;
240 Ok(CheckOutput {
241 well_formed: true,
242 nodes: doc.len(),
243 })
244}
245
246#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
248pub struct InspectOutput {
249 pub root: String,
251 pub max_depth: usize,
253 pub elements: BTreeMap<String, usize>,
255 pub namespaces: BTreeMap<String, usize>,
257}
258
259impl fmt::Display for InspectOutput {
260 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
261 writeln!(f, "Root element: {}", self.root)?;
262 writeln!(f, "Maximum depth: {}", self.max_depth)?;
263 writeln!(f, "Elements:")?;
264 for (name, n) in &self.elements {
265 writeln!(f, " {name}: {n}")?;
266 }
267 if self.namespaces.is_empty() {
272 writeln!(f, "Namespaces: none")
273 } else {
274 writeln!(
275 f,
276 "Namespaces (pass these to xml_query as `namespaces`):"
277 )?;
278 for (uri, n) in &self.namespaces {
279 writeln!(f, " {uri}: {n} element(s)")?;
280 }
281 Ok(())
282 }
283 }
284}
285
286pub fn inspect(xml: &str) -> Result<InspectOutput, String> {
292 let doc = parse_doc(xml)?;
293 let mut elements: BTreeMap<String, usize> = BTreeMap::new();
294 let mut namespaces: BTreeMap<String, usize> = BTreeMap::new();
295 let mut max_depth = 0usize;
296
297 for id in doc.descendants() {
298 if let Some(name) = doc.element_name(id) {
299 *elements.entry(name.local.clone()).or_default() += 1;
300 if let Some(uri) = &name.namespace {
301 *namespaces.entry(uri.clone()).or_default() += 1;
302 }
303 let mut d = 0usize;
304 let mut cur = Some(id);
305 while let Some(n) = cur {
306 cur = doc.parent(n);
307 d += 1;
308 }
309 max_depth = max_depth.max(d);
310 }
311 }
312
313 let root = doc
314 .root_element()
315 .and_then(|r| doc.element_name(r))
316 .map_or_else(|| "none".to_owned(), |n| n.local.clone());
317
318 Ok(InspectOutput {
319 root,
320 max_depth,
321 elements,
322 namespaces,
323 })
324}
325
326#[allow(clippy::doc_markdown, reason = "tool descriptions, shown verbatim")]
334#[derive(Debug, Deserialize, JsonSchema)]
335pub struct QueryArgs {
336 #[schemars(example = &"<library><book lang=\"en\"><title>Dune</title></book></library>")]
338 pub xml: String,
339 #[schemars(example = &"//book/title")]
341 pub xpath: String,
342 #[serde(default)]
347 pub namespaces: BTreeMap<String, String>,
348}
349
350#[derive(Debug, Deserialize, JsonSchema)]
352pub struct ValidateArgs {
353 #[schemars(example = &"<library><book lang=\"en\"><title>Dune</title></book></library>")]
355 pub xml: String,
356 #[schemars(example = &"<xs:schema xmlns:xs=\"http://www.w3.org/2001/XMLSchema\"><xs:element name=\"library\"/></xs:schema>")]
358 pub xsd: String,
359}
360
361#[derive(Debug, Deserialize, JsonSchema)]
363pub struct DocumentArgs {
364 #[schemars(example = &"<library><book lang=\"en\"><title>Dune</title></book></library>")]
366 pub xml: String,
367}
368
369fn reply<T: Serialize + fmt::Display>(
375 outcome: Result<T, String>,
376 is_error: impl FnOnce(&T) -> bool,
377) -> Result<CallToolResult, ErrorData> {
378 match outcome {
379 Ok(value) => {
380 let structured = serde_json::to_value(&value)
381 .map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
382 let content = vec![ContentBlock::text(value.to_string())];
383 let mut result = if is_error(&value) {
384 CallToolResult::error(content)
385 } else {
386 CallToolResult::success(content)
387 };
388 result.structured_content = Some(structured);
389 Ok(result)
390 }
391 Err(message) => {
396 Ok(CallToolResult::error(vec![ContentBlock::text(message)]))
397 }
398 }
399}
400
401#[derive(Debug, Clone)]
406pub struct XmlServer {
407 tool_router: ToolRouter<Self>,
408}
409
410impl Default for XmlServer {
411 fn default() -> Self {
412 Self::new()
413 }
414}
415
416#[tool_router]
417#[allow(
418 clippy::unused_self,
419 reason = "the SDK's tool router calls tools as methods"
420)]
421impl XmlServer {
422 #[must_use]
424 pub fn new() -> Self {
425 Self {
426 tool_router: Self::tool_router(),
427 }
428 }
429
430 #[tool(
431 name = "xml_query",
432 description = "Evaluate an XPath 1.0 expression against an XML \
433 document and return the matching values. Use this \
434 instead of reading a large document into context.",
435 annotations(
436 title = "Query XML with XPath",
437 read_only_hint = true,
438 destructive_hint = false,
439 idempotent_hint = true,
440 open_world_hint = false
441 ),
442 output_schema = schema_for_output::<QueryOutput>()
443 )]
444 fn xml_query(
445 &self,
446 Parameters(args): Parameters<QueryArgs>,
447 ) -> Result<CallToolResult, ErrorData> {
448 let namespaces: Vec<(&str, &str)> = args
449 .namespaces
450 .iter()
451 .map(|(p, u)| (p.as_str(), u.as_str()))
452 .collect();
453 reply(query(&args.xml, &args.xpath, &namespaces), |_| false)
454 }
455
456 #[tool(
457 name = "xml_validate",
458 description = "Validate an XML document against an XML Schema \
459 (XSD). Returns every violation with the path to \
460 the element it concerns.",
461 annotations(
462 title = "Validate XML against an XSD",
463 read_only_hint = true,
464 destructive_hint = false,
465 idempotent_hint = true,
466 open_world_hint = false
467 ),
468 output_schema = schema_for_output::<ValidateOutput>()
469 )]
470 fn xml_validate(
471 &self,
472 Parameters(args): Parameters<ValidateArgs>,
473 ) -> Result<CallToolResult, ErrorData> {
474 reply(validate(&args.xml, &args.xsd), |v| !v.valid)
478 }
479
480 #[tool(
481 name = "xml_check",
482 description = "Check whether a document is well-formed, and \
483 report the line and column if it is not.",
484 annotations(
485 title = "Check XML well-formedness",
486 read_only_hint = true,
487 destructive_hint = false,
488 idempotent_hint = true,
489 open_world_hint = false
490 ),
491 output_schema = schema_for_output::<CheckOutput>()
492 )]
493 fn xml_check(
494 &self,
495 Parameters(args): Parameters<DocumentArgs>,
496 ) -> Result<CallToolResult, ErrorData> {
497 reply(check(&args.xml), |_| false)
498 }
499
500 #[tool(
501 name = "xml_inspect",
502 description = "Summarise a document's structure: element counts, \
503 depth, the element names present, and the \
504 namespaces it uses. Use this to understand a \
505 document's shape before querying it.",
506 annotations(
507 title = "Inspect XML structure",
508 read_only_hint = true,
509 destructive_hint = false,
510 idempotent_hint = true,
511 open_world_hint = false
512 ),
513 output_schema = schema_for_output::<InspectOutput>()
514 )]
515 fn xml_inspect(
516 &self,
517 Parameters(args): Parameters<DocumentArgs>,
518 ) -> Result<CallToolResult, ErrorData> {
519 reply(inspect(&args.xml), |_| false)
520 }
521}
522
523#[tool_handler(router = self.tool_router)]
524#[allow(
525 clippy::unused_async_trait_impl,
526 reason = "the SDK's handler macro generates the trait methods"
527)]
528impl ServerHandler for XmlServer {
529 fn get_info(&self) -> ServerConfig {
530 ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
531 .with_server_info(
532 Implementation::new("oxml-mcp", env!("CARGO_PKG_VERSION"))
533 .with_title("oxml MCP")
534 .with_website_url(env!("CARGO_PKG_REPOSITORY")),
535 )
536 .with_instructions(
537 "XML tools. Documents are passed as strings, never as \
538 paths. xml_inspect reports a document's element names \
539 and namespaces; xml_query evaluates XPath 1.0 against \
540 it; xml_check reports well-formedness; xml_validate \
541 checks it against an XSD.",
542 )
543 }
544
545 async fn call_tool(
553 &self,
554 request: CallToolRequestParams,
555 context: RequestContext<RoleServer>,
556 ) -> Result<CallToolResponse, ErrorData> {
557 if !self.tool_router.has_route(&request.name) {
558 return Ok(CallToolResult::error(vec![ContentBlock::text(
559 format!(
560 "Unknown tool: {}. The tools are xml_query, xml_validate, \
561 xml_check and xml_inspect.",
562 request.name
563 ),
564 )])
565 .into());
566 }
567 let call = ToolCallContext::new(self, request, context);
568 self.tool_router.call(call).await
569 }
570}
571
572#[cfg(test)]
573mod tests {
574 use super::*;
575 use serde_json::{Value, json};
576
577 const DOC: &str = "<library><book lang=\"en\"><title>Dune</title>\
578 </book><book lang=\"fr\"><title>Germinal</title>\
579 </book></library>";
580
581 fn parse<T: serde::de::DeserializeOwned>(v: Value) -> T {
584 serde_json::from_value(v).expect("arguments")
585 }
586
587 fn call(tool: &str, args: Value) -> CallToolResult {
588 let server = XmlServer::new();
589 let result = match tool {
590 "xml_query" => server.xml_query(Parameters(parse(args))),
591 "xml_validate" => server.xml_validate(Parameters(parse(args))),
592 "xml_check" => server.xml_check(Parameters(parse(args))),
593 "xml_inspect" => server.xml_inspect(Parameters(parse(args))),
594 other => panic!("no such tool {other}"),
595 };
596 result.expect("a tool failure is a result, not a protocol error")
597 }
598
599 fn text_of(r: &CallToolResult) -> &str {
600 r.content
601 .first()
602 .and_then(ContentBlock::as_text)
603 .map(|t| t.text.as_str())
604 .expect("text content")
605 }
606
607 fn is_error(r: &CallToolResult) -> bool {
608 r.is_error == Some(true)
609 }
610
611 #[test]
612 fn every_tool_is_registered_with_schema_and_annotations() {
613 let tools = XmlServer::tool_router().list_all();
614 let mut names: Vec<&str> =
615 tools.iter().map(|t| t.name.as_ref()).collect();
616 names.sort_unstable();
617 assert_eq!(
618 names,
619 ["xml_check", "xml_inspect", "xml_query", "xml_validate"]
620 );
621 for t in &tools {
622 assert!(t.description.is_some(), "{} has no description", t.name);
623 assert_eq!(
624 t.input_schema.get("type").and_then(Value::as_str),
625 Some("object")
626 );
627 assert!(t.input_schema.get("properties").is_some());
628 assert!(
629 t.output_schema.is_some(),
630 "{} has no outputSchema",
631 t.name
632 );
633 let a = t.annotations.as_ref().expect("annotations");
634 assert_eq!(a.read_only_hint, Some(true), "{}", t.name);
635 }
636 }
637
638 #[test]
639 fn input_schemas_keep_the_field_names_and_descriptions() {
640 let tools = XmlServer::tool_router().list_all();
641 let query =
642 tools.iter().find(|t| t.name == "xml_query").expect("query");
643 let props = query.input_schema.get("properties").expect("properties");
644 assert_eq!(
645 props["xml"]["description"].as_str(),
646 Some("The XML document")
647 );
648 assert_eq!(
649 props["xpath"]["description"].as_str(),
650 Some("An XPath 1.0 expression")
651 );
652 assert!(
653 props["namespaces"]["description"]
654 .as_str()
655 .is_some_and(|d| d.contains("xml_inspect")),
656 "{props}"
657 );
658 assert_eq!(props["namespaces"]["type"].as_str(), Some("object"));
659 let required =
660 query.input_schema["required"].as_array().expect("required");
661 assert_eq!(required, &[json!("xml"), json!("xpath")]);
662 }
663
664 #[test]
665 fn xml_query_returns_the_selected_text() {
666 let r =
667 call("xml_query", json!({"xml": DOC, "xpath": "//book[1]/title"}));
668 assert!(!is_error(&r));
669 assert!(text_of(&r).contains("Dune"), "{}", text_of(&r));
670 assert_eq!(
671 r.structured_content,
672 Some(json!({"count": 1, "values": ["Dune"]}))
673 );
674 }
675
676 #[test]
677 fn xml_query_reads_attributes() {
678 let r =
679 call("xml_query", json!({"xml": DOC, "xpath": "//book[2]/@lang"}));
680 assert!(!is_error(&r));
681 assert!(text_of(&r).contains("fr"), "{}", text_of(&r));
682 }
683
684 #[test]
685 fn xml_check_accepts_and_rejects() {
686 let good = call("xml_check", json!({"xml": DOC}));
687 assert!(!is_error(&good));
688 assert_eq!(
689 good.structured_content,
690 Some(json!({"well_formed": true, "nodes": 11}))
691 );
692
693 let bad = call("xml_check", json!({"xml": "<a><b></a>"}));
694 assert!(is_error(&bad));
695 assert!(
697 text_of(&bad).contains("line 1, column"),
698 "{}",
699 text_of(&bad)
700 );
701 assert!(bad.structured_content.is_none());
702 }
703
704 #[test]
705 fn xml_inspect_summarises_the_document() {
706 let r = call("xml_inspect", json!({"xml": DOC}));
707 let text = text_of(&r);
708 assert!(text.contains("Root element: library"), "{text}");
709 assert!(text.contains("book: 2"), "{text}");
710 let s = r.structured_content.expect("structured");
711 assert_eq!(s["root"], "library");
712 assert_eq!(s["max_depth"], 4);
713 assert_eq!(s["elements"]["book"], 2);
714 }
715
716 #[test]
717 fn xml_validate_reports_both_outcomes() {
718 let xsd = r#"<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
719 <xs:element name="note" type="xs:string"/>
720 </xs:schema>"#;
721 let ok = call(
722 "xml_validate",
723 json!({"xml": "<note>hi</note>", "xsd": xsd}),
724 );
725 assert!(!is_error(&ok), "{ok:?}");
726 assert_eq!(
727 ok.structured_content,
728 Some(json!({"valid": true, "violations": []}))
729 );
730
731 let bad = call(
732 "xml_validate",
733 json!({"xml": "<wrong>hi</wrong>", "xsd": xsd}),
734 );
735 assert!(is_error(&bad), "{bad:?}");
736 assert!(text_of(&bad).contains("violation(s)"), "{}", text_of(&bad));
737 let s = bad.structured_content.expect("structured");
739 assert_eq!(s["valid"], false);
740 assert!(!s["violations"].as_array().expect("array").is_empty());
741
742 let unreadable =
743 call("xml_validate", json!({"xml": "<a/>", "xsd": "<"}));
744 assert!(is_error(&unreadable));
745 assert!(text_of(&unreadable).contains("schema could not be read"));
746 }
747
748 #[test]
749 fn a_tool_failure_is_a_result_not_a_protocol_error() {
750 let r = XmlServer::new().xml_check(Parameters(DocumentArgs {
754 xml: "<a>".to_owned(),
755 }));
756 let r = r.expect("Ok, not Err");
757 assert!(is_error(&r));
758 }
759
760 #[test]
761 fn a_bound_prefix_selects_only_that_namespace() {
762 let xml =
765 r#"<r xmlns:m="urn:u"><m:item>ns</m:item><item>plain</item></r>"#;
766 let r = call(
767 "xml_query",
768 json!({"xml": xml, "xpath": "//m:item", "namespaces": {"m": "urn:u"}}),
769 );
770 assert!(!is_error(&r), "{r:?}");
771 assert_eq!(text_of(&r), "ns");
772 }
773
774 #[test]
775 fn an_unbound_prefix_says_what_to_do_about_it() {
776 let xml = r#"<r xmlns:m="urn:u"><m:item>ns</m:item></r>"#;
780 let r = call("xml_query", json!({"xml": xml, "xpath": "//m:item"}));
781 assert!(is_error(&r));
782 let text = text_of(&r);
783 assert!(text.contains("namespaces"), "{text}");
784 assert!(text.contains("xml_inspect"), "{text}");
785 }
786
787 #[test]
788 fn inspect_reports_the_namespaces_a_document_uses() {
789 let xml = r#"<r xmlns:m="urn:u"><m:item>ns</m:item></r>"#;
790 let r = call("xml_inspect", json!({"xml": xml}));
791 assert!(text_of(&r).contains("urn:u"), "{}", text_of(&r));
792 assert_eq!(
793 r.structured_content.expect("structured")["namespaces"]["urn:u"],
794 1
795 );
796
797 let plain = call("xml_inspect", json!({"xml": "<r><item/></r>"}));
798 assert!(
799 text_of(&plain).contains("Namespaces: none"),
800 "{}",
801 text_of(&plain)
802 );
803 }
804
805 #[test]
806 fn the_xml_prefix_may_not_be_rebound() {
807 let xml = r#"<r><a xml:lang="en">x</a></r>"#;
810 let r = call(
811 "xml_query",
812 json!({"xml": xml, "xpath": "//@xml:lang", "namespaces": {"xml": "urn:wrong"}}),
813 );
814 assert!(!is_error(&r), "{r:?}");
815 assert_eq!(text_of(&r), "en");
816 }
817
818 #[test]
819 fn a_scalar_expression_returns_its_value() {
820 let r =
823 call("xml_query", json!({"xml": DOC, "xpath": "count(//book)"}));
824 assert!(!is_error(&r));
825 assert_eq!(text_of(&r).trim(), "2");
826 assert_eq!(
827 r.structured_content,
828 Some(json!({"count": 1, "values": ["2"]}))
829 );
830 }
831
832 #[test]
833 fn a_query_matching_nothing_says_so() {
834 let r =
835 call("xml_query", json!({"xml": DOC, "xpath": "//nonexistent"}));
836 assert!(!is_error(&r));
837 assert!(text_of(&r).contains("No nodes matched"), "{}", text_of(&r));
838 assert_eq!(
839 r.structured_content,
840 Some(json!({"count": 0, "values": []}))
841 );
842 }
843
844 #[test]
845 fn matches_with_no_text_report_the_count_instead() {
846 let r = call(
849 "xml_query",
850 json!({"xml": "<r><e/><e/></r>", "xpath": "//e"}),
851 );
852 assert!(!is_error(&r));
853 let text = text_of(&r);
854 assert!(text.contains('2'), "{text}");
855 assert!(text.contains("empty text"), "{text}");
856 assert_eq!(
857 r.structured_content,
858 Some(json!({"count": 2, "values": []}))
859 );
860 }
861
862 #[test]
863 fn an_invalid_xpath_is_reported_as_such() {
864 let r = call("xml_query", json!({"xml": DOC, "xpath": "//["}));
865 assert!(is_error(&r));
866 assert!(
867 text_of(&r)
868 .contains("XPath expression is invalid at line 1, column 3"),
869 "{}",
870 text_of(&r)
871 );
872 }
873
874 #[test]
875 fn an_invalid_multiline_xpath_reports_expression_line_and_column() {
876 let r = call(
877 "xml_query",
878 json!({"xml": DOC, "xpath": "//book[\n@lang = ]"}),
879 );
880 assert!(is_error(&r));
881 assert!(
882 text_of(&r)
883 .contains("XPath expression is invalid at line 2, column 9"),
884 "{}",
885 text_of(&r)
886 );
887 }
888
889 #[test]
890 fn xpath_positions_stop_at_character_boundaries() {
891 assert_eq!(xpath_line_column("é", 1), (1, 1));
894 assert_eq!(xpath_line_column("ab", 9), (1, 3));
895 }
896
897 #[test]
898 fn a_control_character_is_rejected_with_a_location() {
899 let r = call("xml_check", json!({"xml": "<a>\u{1}</a>"}));
902 assert!(is_error(&r));
903 assert!(text_of(&r).contains("line 1"), "{}", text_of(&r));
904 let r = call("xml_check", json!({"xml": "<a>\t</a>"}));
906 assert!(!is_error(&r), "{r:?}");
907 }
908
909 #[test]
910 fn the_server_describes_itself() {
911 let info = XmlServer::default().get_info();
912 assert_eq!(info.server_info.name, "oxml-mcp");
913 assert_eq!(info.server_info.version, env!("CARGO_PKG_VERSION"));
914 assert!(info.capabilities.tools.is_some());
915 assert!(info.instructions.is_some());
916 }
917}