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//! Four tools: query, validate, check, and inspect. 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 four operations are plain functions -- [`query`], [`validate`],
17//! [`check`], [`inspect`] -- 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
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
39/// A parse failure, worded for a model.
40///
41/// The location is what makes the message actionable: a model told
42/// only that the document is malformed will guess at where.
43fn 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/// What an `XPath` expression selected.
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
54pub struct QueryOutput {
55    /// How many nodes matched. A scalar expression counts as one.
56    pub count: usize,
57    /// The matched values in document order, empty text omitted. A
58    /// scalar expression -- a number, string or boolean -- is one
59    /// value.
60    pub values: Vec<String>,
61}
62
63impl fmt::Display for QueryOutput {
64    /// One value per line. When nothing matched, say so: an empty
65    /// string would read to a model as a successful query against an
66    /// empty document.
67    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
82/// Evaluate an `XPath` 1.0 expression against a document.
83///
84/// `namespaces` binds the prefixes the expression uses; a prefix is
85/// never read from the document. The `xml` prefix is bound by the
86/// specification and a binding for it is ignored rather than refused.
87///
88/// # Errors
89///
90/// A document that is not well-formed, or an expression that does not
91/// compile, is reported as text a model can act on: the position of
92/// the fault, or the argument to pass for an unbound prefix.
93pub 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            // The library names a Rust function, which is no use to a
107            // model. Say what it can put in the request.
108            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/// One schema violation.
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
161pub struct Violation {
162    /// The path to the element the violation concerns.
163    pub path: String,
164    /// What is wrong with it.
165    pub message: String,
166}
167
168/// The outcome of validating a document against a schema.
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
170pub struct ValidateOutput {
171    /// Whether the document conforms to the schema.
172    pub valid: bool,
173    /// Every violation found; empty when the document is valid.
174    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
190/// Validate a document against an XML Schema.
191///
192/// A document that violates the schema is a successful validation with
193/// `valid: false`; the violations are the answer.
194///
195/// # Errors
196///
197/// The schema could not be read, or the document is not well-formed.
198/// Neither is a validation result, because nothing was validated.
199pub 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/// A well-formed document.
218#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
219pub struct CheckOutput {
220    /// Always true: a document that is not well-formed is an error,
221    /// not a result.
222    pub well_formed: bool,
223    /// How many nodes the document has.
224    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
233/// Check whether a document is well-formed.
234///
235/// # Errors
236///
237/// The document is not, and the message says where.
238pub 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/// The shape of a document.
247#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
248pub struct InspectOutput {
249    /// The local name of the root element, or `none`.
250    pub root: String,
251    /// The deepest element, counting the root as 1.
252    pub max_depth: usize,
253    /// Every element name present, with how many times it occurs.
254    pub elements: BTreeMap<String, usize>,
255    /// Every namespace URI in use, with how many elements are in it.
256    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        // A model cannot write a namespace-aware query against
268        // namespaces it cannot see, and an unbound prefix is an error
269        // rather than a silent match. Reporting them here is what
270        // makes the `namespaces` argument usable.
271        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
286/// Summarise a document's structure.
287///
288/// # Errors
289///
290/// The document is not well-formed.
291pub 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// The doc comments on the argument structs are the descriptions a
327// client shows the model, kept word for word from the previous
328// release; backticks would change them. The examples are what an
329// auditor or a client with no document of its own sends: a string that
330// happens to be XML rather than one that happens not to be.
331
332/// Arguments of `xml_query`.
333#[allow(clippy::doc_markdown, reason = "tool descriptions, shown verbatim")]
334#[derive(Debug, Deserialize, JsonSchema)]
335pub struct QueryArgs {
336    /// The XML document
337    #[schemars(example = &"<library><book lang=\"en\"><title>Dune</title></book></library>")]
338    pub xml: String,
339    /// An XPath 1.0 expression
340    #[schemars(example = &"//book/title")]
341    pub xpath: String,
342    /// Namespace prefixes used in the expression, mapping prefix to
343    /// URI, e.g. {"m": "urn:example"}. A prefix must be bound here; it
344    /// is not read from the document. Call xml_inspect to see which
345    /// namespaces a document uses.
346    #[serde(default)]
347    pub namespaces: BTreeMap<String, String>,
348}
349
350/// Arguments of `xml_validate`.
351#[derive(Debug, Deserialize, JsonSchema)]
352pub struct ValidateArgs {
353    /// The XML document
354    #[schemars(example = &"<library><book lang=\"en\"><title>Dune</title></book></library>")]
355    pub xml: String,
356    /// The XML Schema
357    #[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/// Arguments of `xml_check` and `xml_inspect`.
362#[derive(Debug, Deserialize, JsonSchema)]
363pub struct DocumentArgs {
364    /// The XML document
365    #[schemars(example = &"<library><book lang=\"en\"><title>Dune</title></book></library>")]
366    pub xml: String,
367}
368
369/// A tool result carrying the same answer twice: as text for the
370/// model and as a structured value for the client.
371///
372/// A failure keeps the text only. The structured schema describes a
373/// result, and an error is not one.
374fn 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        // A tool that ran and could not do the job: a *successful*
392        // JSON-RPC response carrying `isError`, so the model sees the
393        // text and can react to it. A JSON-RPC error would be handled
394        // by the client and never shown.
395        Err(message) => {
396            Ok(CallToolResult::error(vec![ContentBlock::text(message)]))
397        }
398    }
399}
400
401/// The MCP server: the four tools over [`rmcp`].
402///
403/// Cheap to create and to clone; the HTTP transports create one per
404/// session. It holds no document between calls.
405#[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    /// A server with all four tools registered.
423    #[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        // A violation is a tool failure the model must see, so it is
475        // flagged `isError` -- but it is also a complete validation
476        // result, so the structured half is kept.
477        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    /// A tool the server does not have is reported as a tool result,
546    /// not a protocol error.
547    ///
548    /// The SDK's default is `-32602`, which the stateless HTTP revision
549    /// carries as an HTTP 400 -- a transport fault to the client, and
550    /// nothing a model gets to read. A model that misspelt a tool name
551    /// is better served by text saying so.
552    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    /// Call a tool the way a request reaches it: JSON arguments,
582    /// deserialised into the tool's parameter type.
583    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        // The position is what makes the message actionable.
696        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        // A violation is still a complete validation result.
738        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        // MCP distinguishes the two: a bad *document* must come back as
751        // isError content so the model can read and react to it, not as
752        // a JSON-RPC error that the client surfaces as a transport fault.
753        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        // A prefix resolves against bindings supplied with the query,
763        // not against the document.
764        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        // The library's message names a Rust function, which is no use
777        // to a model. The reply must name the argument to pass and the
778        // tool that reveals what to put in it.
779        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        // Bound by the specification; a binding that tries is ignored
808        // rather than failing the request.
809        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        // Not a node-set: the value is the answer, and returning an
821        // empty match here would be wrong.
822        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        // Empty elements match but have nothing to show; silence would
847        // be indistinguishable from no match at all.
848        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        // An offset inside a multi-byte character must not slice the
892        // string mid-character.
893        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        // U+0001 is not a legal XML character; the reply must still be
900        // a readable message, not a panic.
901        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        // A tab *is* legal.
905        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}