Skip to main content

oxml_mcp/
lib.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Copyright (c) 2026 oxml. All rights reserved.
3
4//! `oxml-mcp` — a Model Context Protocol server for XML.
5//!
6//! Five tools: query, validate, check, inspect, and format. The protocol is
7//! handled by [`rmcp`], the official MCP SDK; this crate supplies the
8//! tools and the text a model reads.
9//!
10//! Why a model wants this: an LLM asked to pull a value out of a large
11//! XML document otherwise has to read the whole thing into its
12//! context and pattern-match by eye. An `XPath` tool turns that into a
13//! question with an exact answer, and the document never needs to fit
14//! in the context window.
15//!
16//! The five operations are plain functions -- [`query`], [`validate`],
17//! [`check`], [`inspect`], [`format_xml`] -- and [`XmlServer`] is the handler that
18//! exposes them as MCP tools. Each answer is returned twice: as text
19//! for the model, and as a structured value for a client that wants to
20//! read it without parsing prose.
21
22#![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
42/// A parse failure, worded for a model.
43///
44/// The location is what makes the message actionable: a model told
45/// only that the document is malformed will guess at where.
46fn 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/// What an `XPath` expression selected.
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
57pub struct QueryOutput {
58    /// How many nodes matched. A scalar expression counts as one.
59    pub count: usize,
60    /// The matched values in document order, empty text omitted. A
61    /// scalar expression -- a number, string or boolean -- is one
62    /// value.
63    pub values: Vec<String>,
64}
65
66impl fmt::Display for QueryOutput {
67    /// One value per line. When nothing matched, say so: an empty
68    /// string would read to a model as a successful query against an
69    /// empty document.
70    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
85/// Evaluate an `XPath` 1.0 expression against a document.
86///
87/// `namespaces` binds the prefixes the expression uses; a prefix is
88/// never read from the document. The `xml` prefix is bound by the
89/// specification and a binding for it is ignored rather than refused.
90///
91/// # Errors
92///
93/// A document that is not well-formed, or an expression that does not
94/// compile, is reported as text a model can act on: the position of
95/// the fault, or the argument to pass for an unbound prefix.
96pub 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    // The expression comes from the model, which is to say from
108    // whoever is driving it. `Limits::strict()` bounds its nesting and
109    // operator count so a pathological expression is an error rather
110    // than a stack overflow in the server.
111    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    // The library names a Rust function, which is no use to a model.
145    // Say what it can put in the request.
146    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/// One schema violation.
184#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
185pub struct Violation {
186    /// The path to the element the violation concerns.
187    pub path: String,
188    /// What is wrong with it.
189    pub message: String,
190}
191
192/// The outcome of validating a document against a schema.
193#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
194pub struct ValidateOutput {
195    /// Whether the document conforms to the schema.
196    pub valid: bool,
197    /// Every violation found; empty when the document is valid.
198    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
214/// Validate a document against an XML Schema.
215///
216/// A document that violates the schema is a successful validation with
217/// `valid: false`; the violations are the answer.
218///
219/// # Errors
220///
221/// The schema could not be read, or the document is not well-formed.
222/// Neither is a validation result, because nothing was validated.
223pub 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/// A well-formed document.
245#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
246pub struct CheckOutput {
247    /// Always true: a document that is not well-formed is an error,
248    /// not a result.
249    pub well_formed: bool,
250    /// How many nodes the document has.
251    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
260/// Check whether a document is well-formed.
261///
262/// # Errors
263///
264/// The document is not, and the message says where.
265pub 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/// The shape of a document.
274#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
275pub struct InspectOutput {
276    /// The local name of the root element, or `none`.
277    pub root: String,
278    /// The deepest element, counting the root as 1.
279    pub max_depth: usize,
280    /// Every element name present, with how many times it occurs.
281    pub elements: BTreeMap<String, usize>,
282    /// Every namespace URI in use, with how many elements are in it.
283    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        // A model cannot write a namespace-aware query against
295        // namespaces it cannot see, and an unbound prefix is an error
296        // rather than a silent match. Reporting them here is what
297        // makes the `namespaces` argument usable.
298        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
313/// Summarise a document's structure.
314///
315/// # Errors
316///
317/// The document is not well-formed.
318pub 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/// What formatting a document produced.
354#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
355pub struct FormatOutput {
356    /// Original size of the XML in bytes.
357    pub original_size: usize,
358    /// Formatted size of the XML in bytes.
359    pub formatted_size: usize,
360    /// The formatted XML.
361    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
374/// Format an XML document with configurable indentation and empty element
375/// style, or minify it.
376///
377/// # Errors
378///
379/// A document that is not well-formed is reported as text a model can act on.
380pub 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// The doc comments on the argument structs are the descriptions a
407// client shows the model, kept word for word from the previous
408// release; backticks would change them. The examples are what an
409// auditor or a client with no document of its own sends: a string that
410// happens to be XML rather than one that happens not to be.
411
412/// Arguments of `xml_query`.
413#[allow(clippy::doc_markdown, reason = "tool descriptions, shown verbatim")]
414#[derive(Debug, Deserialize, JsonSchema)]
415pub struct QueryArgs {
416    /// The XML document
417    #[schemars(example = &"<library><book lang=\"en\"><title>Dune</title></book></library>")]
418    pub xml: String,
419    /// An XPath 1.0 expression
420    #[schemars(example = &"//book/title")]
421    pub xpath: String,
422    /// Namespace prefixes used in the expression, mapping prefix to
423    /// URI, e.g. {"m": "urn:example"}. A prefix must be bound here; it
424    /// is not read from the document. Call xml_inspect to see which
425    /// namespaces a document uses.
426    #[serde(default)]
427    pub namespaces: BTreeMap<String, String>,
428}
429
430/// Arguments of `xml_validate`.
431#[derive(Debug, Deserialize, JsonSchema)]
432pub struct ValidateArgs {
433    /// The XML document
434    #[schemars(example = &"<library><book lang=\"en\"><title>Dune</title></book></library>")]
435    pub xml: String,
436    /// The XML Schema
437    #[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/// Arguments of `xml_check` and `xml_inspect`.
442#[derive(Debug, Deserialize, JsonSchema)]
443pub struct DocumentArgs {
444    /// The XML document
445    #[schemars(example = &"<library><book lang=\"en\"><title>Dune</title></book></library>")]
446    pub xml: String,
447}
448
449/// Arguments of `xml_format`.
450#[derive(Debug, Deserialize, JsonSchema)]
451pub struct FormatArgs {
452    /// The XML document
453    #[schemars(example = &"<library><book lang=\"en\"><title>Dune</title></book></library>")]
454    pub xml: String,
455    /// Spaces per indentation level (e.g. 2 or 4). Set to 0 or omit to minify
456    /// without extra whitespace.
457    #[serde(default)]
458    pub indent: Option<u8>,
459    /// Empty element style: 'self-closing' (`<a/>`), 'spaced' (`<a />`), or
460    /// 'expanded' (`<a></a>`). Default is 'self-closing'.
461    #[serde(default)]
462    pub empty_elements: Option<String>,
463}
464
465/// A tool result carrying the same answer twice: as text for the
466/// model and as a structured value for the client.
467///
468/// A failure keeps the text only. The structured schema describes a
469/// result, and an error is not one.
470fn 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        // A tool that ran and could not do the job: a *successful*
488        // JSON-RPC response carrying `isError`, so the model sees the
489        // text and can react to it. A JSON-RPC error would be handled
490        // by the client and never shown.
491        Err(message) => {
492            Ok(CallToolResult::error(vec![ContentBlock::text(message)]))
493        }
494    }
495}
496
497/// The MCP server: the four tools over [`rmcp`].
498///
499/// Cheap to create and to clone; the HTTP transports create one per
500/// session. It holds no document between calls.
501#[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    /// A server with all four tools registered.
519    #[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        // A violation is a tool failure the model must see, so it is
571        // flagged `isError` -- but it is also a complete validation
572        // result, so the structured half is kept.
573        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    /// A tool the server does not have is reported as a tool result,
667    /// not a protocol error.
668    ///
669    /// The SDK's default is `-32602`, which the stateless HTTP revision
670    /// carries as an HTTP 400 -- a transport fault to the client, and
671    /// nothing a model gets to read. A model that misspelt a tool name
672    /// is better served by text saying so.
673    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    /// Call a tool the way a request reaches it: JSON arguments,
703    /// deserialised into the tool's parameter type.
704    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        // The position is what makes the message actionable.
824        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        // A violation is still a complete validation result.
866        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        // MCP distinguishes the two: a bad *document* must come back as
879        // isError content so the model can read and react to it, not as
880        // a JSON-RPC error that the client surfaces as a transport fault.
881        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        // A prefix resolves against bindings supplied with the query,
891        // not against the document.
892        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        // The library's message names a Rust function, which is no use
905        // to a model. The reply must name the argument to pass and the
906        // tool that reveals what to put in it.
907        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        // Bound by the specification; a binding that tries is ignored
936        // rather than failing the request.
937        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        // Not a node-set: the value is the answer, and returning an
949        // empty match here would be wrong.
950        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        // Empty elements match but have nothing to show; silence would
975        // be indistinguishable from no match at all.
976        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        // An offset inside a multi-byte character must not slice the
1020        // string mid-character.
1021        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        // U+0001 is not a legal XML character; the reply must still be
1028        // a readable message, not a panic.
1029        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        // A tab *is* legal.
1033        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        // Minify without indent
1060        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        // Expanded empty elements
1070        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        // Malformed XML returns a tool error
1081        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}