This is an automated email from the ASF dual-hosted git repository.

epugh pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/solr-mcp.git


The following commit(s) were added to refs/heads/main by this push:
     new 20e84a7  feat(indexing): add markdown document indexing (#144)
20e84a7 is described below

commit 20e84a7d33b92970b40e1c3a7ad810c6fb534e7b
Author: Shahzad ASGHAR [United Nations] <[email protected]>
AuthorDate: Fri Sep 11 17:10:25 2026 +0300

    feat(indexing): add markdown document indexing (#144)
    
    * feat(indexing): add markdown document indexing
    
    Adds an index-markdown-documents MCP tool that indexes markdown
    content into Solr, complementing the existing JSON/CSV/XML tools.
    
    - New MarkdownDocumentCreator with CommonMark and YAML front matter
    - Stable SHA-256 content IDs when front matter has no id
    - Multi-valued heading and YAML list fields
    - Markdown support in the index-data prompt
    - Unit and integration coverage
    
    Refs #69
    
    * feat(indexing): steer clients away from markdown tool for structured 
formats
    
    Direct clients to the dedicated JSON, CSV, and XML tools. Convert to
    Markdown only when no dedicated source-format tool exists, and require
    a stable front matter id for converted content.
---
 AGENTS.md                                          |   4 +-
 README.md                                          |   1 +
 build.gradle.kts                                   |   3 +
 gradle/libs.versions.toml                          |   5 +
 .../solr/mcp/server/indexing/IndexingService.java  |  93 ++++++-
 .../documentcreator/IndexingDocumentCreator.java   |  36 ++-
 .../documentcreator/MarkdownDocumentCreator.java   | 281 ++++++++++++++++++++
 .../mcp/server/McpClientIntegrationTestBase.java   |  34 +++
 .../indexing/IndexingServiceIntegrationTest.java   |   4 +-
 .../mcp/server/indexing/IndexingServiceTest.java   |  16 ++
 .../mcp/server/indexing/MarkdownIndexingTest.java  | 287 +++++++++++++++++++++
 11 files changed, 753 insertions(+), 11 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md
index 1f4cd3a..89db734 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -99,7 +99,7 @@ docker run -p 8080:8080 --rm -e PROFILES=http \
 Four service classes expose MCP tools via `@McpTool` annotations:
 
 - **SearchService** (`search/`) - Full-text search with filtering, faceting, 
sorting, pagination
-- **IndexingService** (`indexing/`) - Document indexing supporting JSON, CSV, 
XML formats
+- **IndexingService** (`indexing/`) - Document indexing supporting JSON, CSV, 
XML, and markdown formats
 - **CollectionService** (`collection/`) - List collections, get stats, health 
checks
 - **SchemaService** (`schema/`) - Schema introspection and additive 
modification (add-fields, add-field-types)
 
@@ -107,7 +107,7 @@ Four service classes expose MCP tools via `@McpTool` 
annotations:
 
 `indexing/documentcreator/` uses strategy pattern for format parsing:
 - `SolrDocumentCreator` - Common interface
-- `JsonDocumentCreator`, `CsvDocumentCreator`, `XmlDocumentCreator` - Format 
implementations
+- `JsonDocumentCreator`, `CsvDocumentCreator`, `XmlDocumentCreator`, 
`MarkdownDocumentCreator` - Format implementations
 - `IndexingDocumentCreator` - Orchestrator that delegates to format-specific 
creators
 - `FieldNameSanitizer` - Automatic field name validation for Solr compatibility
 
diff --git a/README.md b/README.md
index ec3c5e1..0d0c1f7 100644
--- a/README.md
+++ b/README.md
@@ -99,6 +99,7 @@ Using a different client, or want STDIO/HTTP/Docker options? 
See the per-client
 | `index-json-documents` | Index documents from a JSON string into a 
collection |
 | `index-csv-documents` | Index documents from a CSV string into a collection |
 | `index-xml-documents` | Index documents from an XML string into a collection 
|
+| `index-markdown-documents` | Index a markdown document into a collection, 
extracting front matter, title, headings, and body text |
 | `create-collection` | Create a collection (configSet, numShards, 
replicationFactor optional — default `_default`, `1`, `1`) |
 | `list-collections` | List all available Solr collections |
 | `get-collection-stats` | Get statistics and metrics for a collection |
diff --git a/build.gradle.kts b/build.gradle.kts
index 6a846c9..094209e 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -151,6 +151,9 @@ dependencies {
     implementation(libs.spring.ai.starter.mcp.server.webmvc)
     implementation(libs.solr.solrj)
     implementation(libs.commons.csv)
+    // CommonMark for markdown parsing
+    implementation(libs.commonmark)
+    implementation(libs.commonmark.ext.yaml.front.matter)
     // JSpecify for nullability annotations
     implementation(libs.jspecify)
 
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 473ff9f..b412931 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -28,6 +28,7 @@ cyclonedx-plugin = "2.4.1"
 spring-ai = "1.1.7"
 solr = "10.0.0"
 commons-csv = "1.14.1"
+commonmark = "0.28.0"
 jspecify = "1.0.0"
 mcp-server-security = "0.0.6"
 
@@ -66,6 +67,10 @@ solr-solrj = { module = "org.apache.solr:solr-solrj", 
version.ref = "solr" }
 # Apache Commons
 commons-csv = { module = "org.apache.commons:commons-csv", version.ref = 
"commons-csv" }
 
+# CommonMark (markdown parsing)
+commonmark = { module = "org.commonmark:commonmark", version.ref = 
"commonmark" }
+commonmark-ext-yaml-front-matter = { module = 
"org.commonmark:commonmark-ext-yaml-front-matter", version.ref = "commonmark" }
+
 # Null safety
 jspecify = { module = "org.jspecify:jspecify", version.ref = "jspecify" }
 
diff --git 
a/src/main/java/org/apache/solr/mcp/server/indexing/IndexingService.java 
b/src/main/java/org/apache/solr/mcp/server/indexing/IndexingService.java
index 3467485..5ac3704 100644
--- a/src/main/java/org/apache/solr/mcp/server/indexing/IndexingService.java
+++ b/src/main/java/org/apache/solr/mcp/server/indexing/IndexingService.java
@@ -42,11 +42,11 @@ import org.xml.sax.SAXException;
  * Apache Solr collections through Model Context Protocol (MCP) integration.
  *
  * <p>
- * This service handles the conversion of JSON, CSV, and XML documents into
- * Solr-compatible format and manages the indexing process with robust error
- * handling and batch processing capabilities. It employs a schema-less 
approach
- * where Solr automatically detects field types, eliminating the need for
- * predefined schema configuration.
+ * This service handles the conversion of JSON, CSV, XML, and markdown 
documents
+ * into Solr-compatible format and manages the indexing process with robust
+ * error handling and batch processing capabilities. It employs a schema-less
+ * approach where Solr automatically detects field types, eliminating the need
+ * for predefined schema configuration.
  *
  * <p>
  * <strong>Core Features:</strong>
@@ -60,6 +60,8 @@ import org.xml.sax.SAXException;
  * with headers
  * <li><strong>XML Processing</strong>: Support for XML documents with element
  * flattening and attribute handling
+ * <li><strong>Markdown Processing</strong>: Support for markdown documents 
with
+ * front matter, title, and heading extraction
  * <li><strong>Batch Processing</strong>: Efficient bulk indexing with
  * configurable batch sizes
  * <li><strong>Error Resilience</strong>: Individual document fallback when
@@ -393,6 +395,81 @@ public class IndexingService {
                                + collection + "'" + 
describeIndexedFields(schemalessDoc);
        }
 
+       /**
+        * Indexes a document from a markdown string into a specified Solr 
collection.
+        *
+        * <p>
+        * This method serves as the primary entry point for markdown document 
indexing
+        * operations and is exposed as an MCP tool for AI client interactions. 
Unlike
+        * the structured formats (JSON, CSV, XML), markdown is a prose format, 
so
+        * searchable structure is extracted from the document content itself.
+        *
+        * <p>
+        * <strong>Field Extraction:</strong>
+        *
+        * <ul>
+        * <li><strong>YAML Front Matter</strong>: Each entry becomes a 
document field
+        * with a sanitized name (multi-valued where applicable)
+        * <li><strong>title</strong>: From the {@code title} front matter 
entry, or the
+        * first level-1 heading
+        * <li><strong>headings</strong>: Multi-valued field with the text of 
every
+        * heading (the document outline)
+        * <li><strong>content</strong>: Plain text body for full-text search 
(front
+        * matter excluded)
+        * </ul>
+        *
+        * <p>
+        * <strong>MCP Tool Usage:</strong>
+        *
+        * <p>
+        * AI clients can invoke this method with natural language requests 
like "index
+        * this markdown file into my_collection" or "add this README to the 
search
+        * index".
+        *
+        * <p>
+        * <strong>Example Markdown Processing:</strong>
+        *
+        * <pre>{@code
+        * Input:
+        * ---
+        * author: Jane Doe
+        * ---
+        * # Getting Started
+        * Run the installer.
+        *
+        * Result: {author:"Jane Doe", title:"Getting Started",
+        *          headings:["Getting Started"], content:"Getting Started\nRun 
the installer."}
+        * }</pre>
+        *
+        * @param collection
+        *            the name of the Solr collection to index documents into
+        * @param markdown
+        *            markdown string to index, optionally starting with YAML 
front
+        *            matter
+        * @throws IOException
+        *             if there are critical errors in Solr communication
+        * @throws SolrServerException
+        *             if Solr server encounters errors during indexing
+        * @see 
IndexingDocumentCreator#createSchemalessDocumentsFromMarkdown(String)
+        * @see #indexDocuments(String, List)
+        */
+       @PreAuthorize("isAuthenticated()")
+       @McpTool(
+                       name = "index-markdown-documents",
+                       annotations = @McpTool.McpAnnotations(idempotentHint = 
true),
+                       description = "Index a document from markdown String 
into Solr collection, extracting front matter, title, headings, and body text. "
+                                       + "Do NOT use for JSON/CSV/XML input; 
use index-json-documents, index-csv-documents, or index-xml-documents instead. "
+                                       + "Only convert source content to 
markdown when there is no dedicated tool for the source format, and supply a 
stable 'id' in the YAML front matter when doing so.")
+       public String indexMarkdownDocuments(@McpToolParam(description = "Solr 
collection to index into") String collection,
+                       @McpToolParam(
+                                       description = "Markdown string to 
index, optionally starting with YAML front matter") String markdown)
+                       throws IOException, SolrServerException {
+               List<SolrInputDocument> schemalessDoc = 
indexingDocumentCreator.createSchemalessDocumentsFromMarkdown(markdown);
+               int successCount = indexDocuments(collection, schemalessDoc);
+               return "Successfully indexed " + successCount + " of " + 
schemalessDoc.size() + " documents into collection '"
+                               + collection + "'";
+       }
+
        /**
         * Indexes a list of SolrInputDocument objects into a Solr collection 
using
         * batch processing.
@@ -532,7 +609,9 @@ public class IndexingService {
                        case "json" -> new IndexTool("index-json-documents", 
"json");
                        case "csv" -> new IndexTool("index-csv-documents", 
"csv");
                        case "xml" -> new IndexTool("index-xml-documents", 
"xml");
-                       default -> throw new IllegalArgumentException("format 
must be one of json/csv/xml, got: " + format);
+                       case "markdown", "md" -> new 
IndexTool("index-markdown-documents", "markdown");
+                       default ->
+                               throw new IllegalArgumentException("format must 
be one of json/csv/xml/markdown, got: " + format);
                };
        }
 
@@ -563,7 +642,7 @@ public class IndexingService {
                                        required = true) String collection,
                        @McpArg(
                                        name = "format",
-                                       description = "Document format: 'json', 
'csv', or 'xml'",
+                                       description = "Document format: 'json', 
'csv', 'xml', or 'markdown'",
                                        required = true) String format,
                        @McpArg(
                                        name = "sample",
diff --git 
a/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/IndexingDocumentCreator.java
 
b/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/IndexingDocumentCreator.java
index 31e3b91..d180171 100644
--- 
a/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/IndexingDocumentCreator.java
+++ 
b/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/IndexingDocumentCreator.java
@@ -44,6 +44,8 @@ import org.springframework.stereotype.Service;
  * with headers
  * <li><strong>XML Processing</strong>: Support for XML documents with element
  * flattening and attribute handling
+ * <li><strong>Markdown Processing</strong>: Support for markdown documents 
with
+ * front matter, title, and heading extraction
  * <li><strong>Field Sanitization</strong>: Automatic cleanup of field names 
for
  * Solr compatibility
  * </ul>
@@ -62,6 +64,8 @@ public class IndexingDocumentCreator {
 
        private final JsonDocumentCreator jsonDocumentCreator;
 
+       private final MarkdownDocumentCreator markdownDocumentCreator;
+
        /**
         * Constructs the orchestrator with the per-format document creators.
         *
@@ -71,12 +75,15 @@ public class IndexingDocumentCreator {
         *            converts CSV input into {@code SolrInputDocument} batches
         * @param jsonDocumentCreator
         *            converts JSON input into {@code SolrInputDocument} batches
+        * @param markdownDocumentCreator
+        *            converts Markdown input into {@code SolrInputDocument} 
batches
         */
        public IndexingDocumentCreator(XmlDocumentCreator xmlDocumentCreator, 
CsvDocumentCreator csvDocumentCreator,
-                       JsonDocumentCreator jsonDocumentCreator) {
+                       JsonDocumentCreator jsonDocumentCreator, 
MarkdownDocumentCreator markdownDocumentCreator) {
                this.xmlDocumentCreator = xmlDocumentCreator;
                this.csvDocumentCreator = csvDocumentCreator;
                this.jsonDocumentCreator = jsonDocumentCreator;
+               this.markdownDocumentCreator = markdownDocumentCreator;
        }
 
        /**
@@ -144,4 +151,31 @@ public class IndexingDocumentCreator {
 
                return xmlDocumentCreator.create(xml);
        }
+
+       /**
+        * Creates a list of schema-less SolrInputDocument objects from a 
markdown
+        * string.
+        *
+        * <p>
+        * This method delegates markdown processing to the 
MarkdownDocumentCreator
+        * utility class.
+        *
+        * @param markdown
+        *            markdown string containing document content, optionally 
starting
+        *            with YAML front matter
+        * @return list of SolrInputDocument objects ready for indexing
+        * @throws DocumentProcessingException
+        *             if markdown parsing fails or input validation fails
+        * @see MarkdownDocumentCreator
+        */
+       public List<SolrInputDocument> 
createSchemalessDocumentsFromMarkdown(String markdown)
+                       throws DocumentProcessingException {
+
+               // Input validation
+               if (markdown == null || markdown.trim().isEmpty()) {
+                       throw new DocumentProcessingException("Markdown input 
cannot be null or empty");
+               }
+
+               return markdownDocumentCreator.create(markdown);
+       }
 }
diff --git 
a/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/MarkdownDocumentCreator.java
 
b/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/MarkdownDocumentCreator.java
new file mode 100644
index 0000000..db56020
--- /dev/null
+++ 
b/src/main/java/org/apache/solr/mcp/server/indexing/documentcreator/MarkdownDocumentCreator.java
@@ -0,0 +1,281 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.solr.mcp.server.indexing.documentcreator;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.HexFormat;
+import java.util.List;
+import org.apache.solr.common.SolrInputDocument;
+import org.commonmark.Extension;
+import org.commonmark.ext.front.matter.YamlFrontMatterExtension;
+import org.commonmark.ext.front.matter.YamlFrontMatterVisitor;
+import org.commonmark.node.AbstractVisitor;
+import org.commonmark.node.Code;
+import org.commonmark.node.CustomBlock;
+import org.commonmark.node.Heading;
+import org.commonmark.node.Node;
+import org.commonmark.node.Text;
+import org.commonmark.parser.Parser;
+import org.commonmark.renderer.text.TextContentRenderer;
+import org.jspecify.annotations.Nullable;
+import org.springframework.stereotype.Component;
+
+/**
+ * Utility class for processing markdown documents and converting them to
+ * SolrInputDocument objects.
+ *
+ * <p>
+ * Unlike the structured formats (JSON, CSV, XML), markdown is a prose format,
+ * so this creator extracts searchable structure from the document rather than
+ * mapping fields one-to-one. Parsing is performed with the CommonMark library,
+ * which is lightweight and reflection-free (GraalVM native-image safe).
+ *
+ * <p>
+ * <strong>Field Extraction Rules:</strong>
+ *
+ * <ul>
+ * <li><strong>YAML Front Matter</strong>: Each front matter entry becomes a
+ * document field with a sanitized name. Entries with multiple values become
+ * multi-valued fields.
+ * <li><strong>id</strong>: Taken from the {@code id} front matter entry when
+ * present, otherwise derived deterministically from a SHA-256 hash of the 
input
+ * so that re-indexing the same markdown overwrites the same document (keeping
+ * the operation idempotent).
+ * <li><strong>title</strong>: Taken from the {@code title} front matter entry
+ * when present, otherwise from the first level-1 heading.
+ * <li><strong>headings</strong>: Multi-valued field containing the text of
+ * every heading, preserving the document outline for searching.
+ * <li><strong>content</strong>: The plain text of the document body (front
+ * matter excluded), suitable for full-text search.
+ * </ul>
+ *
+ * <p>
+ * <strong>Example Transformation:</strong>
+ *
+ * <pre>{@code
+ * Input markdown:
+ * ---
+ * author: Jane Doe
+ * tags: [search, solr]
+ * ---
+ * # Getting Started
+ * ## Installation
+ * Run the installer.
+ *
+ * Output document:
+ * {author:"Jane Doe", tags:["search","solr"], title:"Getting Started",
+ *  headings:["Getting Started","Installation"], content:"Getting 
Started\nInstallation\nRun the installer."}
+ * }</pre>
+ *
+ * @see SolrInputDocument
+ * @see FieldNameSanitizer#sanitizeFieldName(String)
+ */
+@Component
+public class MarkdownDocumentCreator implements SolrDocumentCreator {
+
+       private static final int MAX_INPUT_SIZE_BYTES = 10 * 1024 * 1024;
+
+       /** Solr field holding the document's unique key. */
+       public static final String FIELD_ID = "id";
+
+       /** Solr field holding the document title. */
+       public static final String FIELD_TITLE = "title";
+
+       /** Multi-valued Solr field holding the text of every heading. */
+       public static final String FIELD_HEADINGS = "headings";
+
+       /** Solr field holding the plain text body of the document. */
+       public static final String FIELD_CONTENT = "content";
+
+       private final Parser parser;
+
+       private final TextContentRenderer textContentRenderer;
+
+       public MarkdownDocumentCreator() {
+               List<Extension> extensions = 
List.of(YamlFrontMatterExtension.create());
+               this.parser = Parser.builder().extensions(extensions).build();
+               this.textContentRenderer = 
TextContentRenderer.builder().build();
+       }
+
+       /**
+        * Creates a SolrInputDocument from a markdown string.
+        *
+        * <p>
+        * The whole input is treated as a single document: front matter 
entries map to
+        * fields, the title is resolved from front matter or the first level-1 
heading,
+        * all heading texts are collected into a multi-valued {@code headings} 
field,
+        * and the plain text body is stored in {@code content}.
+        *
+        * @param markdown
+        *            markdown string, optionally starting with YAML front 
matter
+        * @return a single-element list containing the created document, or an 
empty
+        *         list if the input is blank
+        * @throws DocumentProcessingException
+        *             if the input exceeds the size limit or parsing fails
+        */
+       @Override
+       public List<SolrInputDocument> create(String markdown) throws 
DocumentProcessingException {
+               if (markdown.getBytes(StandardCharsets.UTF_8).length > 
MAX_INPUT_SIZE_BYTES) {
+                       throw new DocumentProcessingException(
+                                       "Input too large: exceeds maximum size 
of " + MAX_INPUT_SIZE_BYTES + " bytes");
+               }
+
+               if (markdown.trim().isEmpty()) {
+                       return List.of();
+               }
+
+               Node document;
+               try {
+                       document = parser.parse(markdown);
+               } catch (RuntimeException e) {
+                       throw new DocumentProcessingException("Failed to parse 
markdown document", e);
+               }
+
+               SolrInputDocument doc = new SolrInputDocument();
+
+               addFrontMatterFields(document, doc);
+
+               // Solr's default schema requires a unique key. A 
content-derived id keeps
+               // re-indexing of the same markdown idempotent (same input, 
same document)
+               if (doc.getFieldValue(FIELD_ID) == null) {
+                       doc.addField(FIELD_ID, contentHash(markdown));
+               }
+
+               HeadingCollector headingCollector = new HeadingCollector();
+               document.accept(headingCollector);
+               for (String heading : headingCollector.headings) {
+                       doc.addField(FIELD_HEADINGS, heading);
+               }
+
+               // Front matter title wins; otherwise fall back to the first 
level-1 heading
+               if (doc.getFieldValue(FIELD_TITLE) == null && 
headingCollector.firstTopLevelHeading != null) {
+                       doc.addField(FIELD_TITLE, 
headingCollector.firstTopLevelHeading);
+               }
+
+               String content = textContentRenderer.render(document).trim();
+               if (!content.isEmpty()) {
+                       doc.addField(FIELD_CONTENT, content);
+               }
+
+               return List.of(doc);
+       }
+
+       /**
+        * Extracts YAML front matter entries into document fields and unlinks 
the front
+        * matter block so it is excluded from the rendered body content.
+        */
+       private void addFrontMatterFields(Node document, SolrInputDocument doc) 
{
+               YamlFrontMatterVisitor frontMatterVisitor = new 
YamlFrontMatterVisitor();
+               document.accept(frontMatterVisitor);
+
+               frontMatterVisitor.getData().forEach((key, values) -> {
+                       String fieldName = 
FieldNameSanitizer.sanitizeFieldName(key);
+                       for (String value : flattenFlowSequences(values)) {
+                               if (!value.isEmpty()) {
+                                       doc.addField(fieldName, value);
+                               }
+                       }
+               });
+
+               // The front matter block is metadata, not body text: remove it 
so the
+               // TextContentRenderer output contains only the document body
+               Node firstChild = document.getFirstChild();
+               if (firstChild instanceof CustomBlock) {
+                       firstChild.unlink();
+               }
+       }
+
+       /**
+        * Expands simple YAML flow sequences into individual values.
+        *
+        * <p>
+        * The CommonMark front matter extension parses block-style lists
+        * ({@code - item}) into multiple values but passes flow-style lists
+        * ({@code [a, b, c]}) through as a single literal string. Flow style 
is common
+        * for tags in real-world markdown (Jekyll, Hugo), so split it here to 
produce
+        * the same multi-valued field either way. Values containing commas 
inside
+        * quotes are not supported and are kept as-is.
+        */
+       private static List<String> flattenFlowSequences(List<String> values) {
+               List<String> result = new ArrayList<>(values.size());
+               for (String value : values) {
+                       String trimmed = value.trim();
+                       if (trimmed.length() >= 2 && trimmed.startsWith("[") && 
trimmed.endsWith("]") && !trimmed.contains("\"")
+                                       && !trimmed.contains("'")) {
+                               for (String element : trimmed.substring(1, 
trimmed.length() - 1).split(",")) {
+                                       result.add(element.trim());
+                               }
+                       } else {
+                               result.add(value);
+                       }
+               }
+               return result;
+       }
+
+       private static String contentHash(String markdown) {
+               try {
+                       MessageDigest digest = 
MessageDigest.getInstance("SHA-256");
+                       return 
HexFormat.of().formatHex(digest.digest(markdown.getBytes(StandardCharsets.UTF_8)));
+               } catch (NoSuchAlgorithmException e) {
+                       // SHA-256 is guaranteed to be available on every Java 
platform
+                       throw new IllegalStateException("SHA-256 MessageDigest 
not available", e);
+               }
+       }
+
+       /**
+        * AST visitor collecting heading texts and the first level-1 heading 
for use as
+        * a title fallback.
+        */
+       private static final class HeadingCollector extends AbstractVisitor {
+
+               private final List<String> headings = new ArrayList<>();
+
+               @Nullable private String firstTopLevelHeading;
+
+               @Override
+               public void visit(Heading heading) {
+                       String text = collectText(heading).trim();
+                       if (!text.isEmpty()) {
+                               headings.add(text);
+                               if (firstTopLevelHeading == null && 
heading.getLevel() == 1) {
+                                       firstTopLevelHeading = text;
+                               }
+                       }
+                       visitChildren(heading);
+               }
+
+               private static String collectText(Node node) {
+                       StringBuilder builder = new StringBuilder();
+                       appendText(node, builder);
+                       return builder.toString();
+               }
+
+               private static void appendText(Node node, StringBuilder 
builder) {
+                       if (node instanceof Text text) {
+                               builder.append(text.getLiteral());
+                       } else if (node instanceof Code code) {
+                               builder.append(code.getLiteral());
+                       }
+                       for (Node child = node.getFirstChild(); child != null; 
child = child.getNext()) {
+                               appendText(child, builder);
+                       }
+               }
+       }
+}
diff --git 
a/src/test/java/org/apache/solr/mcp/server/McpClientIntegrationTestBase.java 
b/src/test/java/org/apache/solr/mcp/server/McpClientIntegrationTestBase.java
index d354082..3b9f230 100644
--- a/src/test/java/org/apache/solr/mcp/server/McpClientIntegrationTestBase.java
+++ b/src/test/java/org/apache/solr/mcp/server/McpClientIntegrationTestBase.java
@@ -104,6 +104,7 @@ public abstract class McpClientIntegrationTestBase {
 
                assertTrue(toolNames.contains("create-collection"), "Should 
have create-collection tool");
                assertTrue(toolNames.contains("index-json-documents"), "Should 
have index-json-documents tool");
+               assertTrue(toolNames.contains("index-markdown-documents"), 
"Should have index-markdown-documents tool");
                assertTrue(toolNames.contains("search"), "Should have search 
tool");
                assertTrue(toolNames.contains("list-collections"), "Should have 
list-collections tool");
                assertTrue(toolNames.contains("check-health"), "Should have 
check-health tool");
@@ -135,6 +136,7 @@ public abstract class McpClientIntegrationTestBase {
                assertHint(tools, "index-json-documents", false, true, true);
                assertHint(tools, "index-csv-documents", false, true, true);
                assertHint(tools, "index-xml-documents", false, true, true);
+               assertHint(tools, "index-markdown-documents", false, true, 
true);
        }
 
        private static void assertReadOnly(Map<String, Tool> tools, String 
name) {
@@ -398,6 +400,38 @@ public abstract class McpClientIntegrationTestBase {
                assertEquals(1, getNumFound(r2), "Multi-valued 'genres' should 
match on 'crime'");
        }
 
+       // Self-contained markdown round-trip: independent of the other 
order-18 test
+       // (the markdown document carries none of the fields its filter queries 
touch).
+       @Test
+       @Order(18)
+       void indexMarkdownDocumentAndFindItById() throws Exception {
+               String markdown = """
+                               ---
+                               id: md-doc-1
+                               author: Markdown Author
+                               ---
+                               # Markdown Indexing Guide
+
+                               ## Installation
+
+                               Index markdown documents through the MCP server.
+                               """;
+
+               CallToolResult indexResult = mcpClient.callTool(new 
CallToolRequest("index-markdown-documents",
+                               Map.of("collection", COLLECTION, "markdown", 
markdown)));
+
+               assertNotNull(indexResult);
+               assertNotError(indexResult);
+               assertTrue(extractText(indexResult).contains("Successfully 
indexed 1"),
+                               "Markdown indexing should report one indexed 
document: " + extractText(indexResult));
+
+               CallToolResult searchResult = mcpClient
+                               .callTool(new CallToolRequest("search", 
Map.of("collection", COLLECTION, "query", "id:md-doc-1")));
+               Map<String, Object> response = 
OBJECT_MAPPER.readValue(extractText(searchResult), new TypeReference<>() {
+               });
+               assertEquals(1, getNumFound(response), "Should find the 
markdown document by its front matter id");
+       }
+
        // ===== End-to-end shows workflow (orders 19–27) =====
        // Exercises the canonical "set up a new collection with a defined 
schema, index
        // real data, then search and introspect" workflow purely through MCP 
tool
diff --git 
a/src/test/java/org/apache/solr/mcp/server/indexing/IndexingServiceIntegrationTest.java
 
b/src/test/java/org/apache/solr/mcp/server/indexing/IndexingServiceIntegrationTest.java
index e4f7516..ae29146 100644
--- 
a/src/test/java/org/apache/solr/mcp/server/indexing/IndexingServiceIntegrationTest.java
+++ 
b/src/test/java/org/apache/solr/mcp/server/indexing/IndexingServiceIntegrationTest.java
@@ -27,6 +27,7 @@ import org.apache.solr.mcp.server.TestcontainersConfiguration;
 import org.apache.solr.mcp.server.indexing.documentcreator.CsvDocumentCreator;
 import 
org.apache.solr.mcp.server.indexing.documentcreator.IndexingDocumentCreator;
 import org.apache.solr.mcp.server.indexing.documentcreator.JsonDocumentCreator;
+import 
org.apache.solr.mcp.server.indexing.documentcreator.MarkdownDocumentCreator;
 import org.apache.solr.mcp.server.indexing.documentcreator.XmlDocumentCreator;
 import org.apache.solr.mcp.server.search.SearchResponse;
 import org.apache.solr.mcp.server.search.SearchService;
@@ -72,9 +73,10 @@ class IndexingServiceIntegrationTest {
                CsvDocumentCreator csvDocumentCreator = new 
CsvDocumentCreator();
                JsonDocumentCreator jsonDocumentCreator = new 
JsonDocumentCreator(
                                new 
com.fasterxml.jackson.databind.ObjectMapper());
+               MarkdownDocumentCreator markdownDocumentCreator = new 
MarkdownDocumentCreator();
 
                indexingDocumentCreator = new 
IndexingDocumentCreator(xmlDocumentCreator, csvDocumentCreator,
-                               jsonDocumentCreator);
+                               jsonDocumentCreator, markdownDocumentCreator);
 
                indexingService = new IndexingService(solrClient, 
indexingDocumentCreator);
                searchService = new SearchService(solrClient);
diff --git 
a/src/test/java/org/apache/solr/mcp/server/indexing/IndexingServiceTest.java 
b/src/test/java/org/apache/solr/mcp/server/indexing/IndexingServiceTest.java
index 88c749e..b1491d1 100644
--- a/src/test/java/org/apache/solr/mcp/server/indexing/IndexingServiceTest.java
+++ b/src/test/java/org/apache/solr/mcp/server/indexing/IndexingServiceTest.java
@@ -354,6 +354,22 @@ class IndexingServiceTest {
                assertTrue(body.contains("index-xml-documents"), "XML path 
should reference index-xml-documents tool");
        }
 
+       @Test
+       void indexDataPrompt_markdownPath_referencesIndexMarkdownDocuments() {
+               String body = indexingService.indexDataPrompt("library", 
"markdown", null);
+
+               assertTrue(body.contains("index-markdown-documents"),
+                               "Markdown path should reference 
index-markdown-documents tool");
+       }
+
+       @Test
+       void indexDataPrompt_mdAliasResolvesToMarkdownTool() {
+               String body = indexingService.indexDataPrompt("library", "md", 
null);
+
+               assertTrue(body.contains("index-markdown-documents"),
+                               "'md' alias should reference 
index-markdown-documents tool");
+       }
+
        @Test
        void indexDataPrompt_unknownFormat_throwsIllegalArgumentException() {
                IllegalArgumentException ex = 
assertThrows(IllegalArgumentException.class,
diff --git 
a/src/test/java/org/apache/solr/mcp/server/indexing/MarkdownIndexingTest.java 
b/src/test/java/org/apache/solr/mcp/server/indexing/MarkdownIndexingTest.java
new file mode 100644
index 0000000..2ec3a7c
--- /dev/null
+++ 
b/src/test/java/org/apache/solr/mcp/server/indexing/MarkdownIndexingTest.java
@@ -0,0 +1,287 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.solr.mcp.server.indexing;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.util.List;
+import org.apache.solr.common.SolrInputDocument;
+import 
org.apache.solr.mcp.server.indexing.documentcreator.DocumentProcessingException;
+import 
org.apache.solr.mcp.server.indexing.documentcreator.IndexingDocumentCreator;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.TestPropertySource;
+
+/**
+ * Test class for markdown indexing functionality in IndexingService.
+ *
+ * <p>
+ * This test verifies that the IndexingService can correctly parse markdown
+ * content — including YAML front matter, headings, and body text — and convert
+ * it into SolrInputDocument objects using the schema-less approach.
+ */
+@SpringBootTest
+@TestPropertySource(locations = "classpath:application.properties")
+class MarkdownIndexingTest {
+
+       @Autowired
+       private IndexingDocumentCreator indexingDocumentCreator;
+
+       @Test
+       void testCreateSchemalessDocumentsFromMarkdownWithFrontMatter() throws 
Exception {
+               // Given
+               String markdown = """
+                               ---
+                               author: George R.R. Martin
+                               genre: fantasy
+                               published: 1996
+                               ---
+                               # A Game of Thrones
+
+                               ## Synopsis
+
+                               The first book in A Song of Ice and Fire.
+
+                               ## Reception
+
+                               Widely acclaimed.
+                               """;
+
+               // When
+               List<SolrInputDocument> documents = 
indexingDocumentCreator.createSchemalessDocumentsFromMarkdown(markdown);
+
+               // Then
+               assertThat(documents).hasSize(1);
+
+               SolrInputDocument doc = documents.getFirst();
+               assertThat(doc.getFieldValue("author")).isEqualTo("George R.R. 
Martin");
+               assertThat(doc.getFieldValue("genre")).isEqualTo("fantasy");
+               assertThat(doc.getFieldValue("published")).isEqualTo("1996");
+
+               // Title comes from the first level-1 heading (no front matter 
title)
+               assertThat(doc.getFieldValue("title")).isEqualTo("A Game of 
Thrones");
+
+               // All heading texts are collected into a multi-valued field
+               assertThat(doc.getFieldValues("headings")).containsExactly("A 
Game of Thrones", "Synopsis", "Reception");
+
+               // Body text is searchable and excludes front matter
+               String content = (String) doc.getFieldValue("content");
+               assertThat(content).contains("The first book in A Song of Ice 
and Fire.");
+               assertThat(content).contains("Widely acclaimed.");
+               assertThat(content).doesNotContain("George R.R. Martin");
+       }
+
+       @Test
+       void testFrontMatterTitleWinsOverFirstHeading() throws Exception {
+               // Given
+               String markdown = """
+                               ---
+                               title: Front Matter Title
+                               ---
+                               # Heading Title
+
+                               Some body text.
+                               """;
+
+               // When
+               List<SolrInputDocument> documents = 
indexingDocumentCreator.createSchemalessDocumentsFromMarkdown(markdown);
+
+               // Then
+               assertThat(documents).hasSize(1);
+               
assertThat(documents.getFirst().getFieldValue("title")).isEqualTo("Front Matter 
Title");
+       }
+
+       @Test
+       void testCreateSchemalessDocumentsFromMarkdownWithoutFrontMatter() 
throws Exception {
+               // Given
+               String markdown = """
+                               # Getting Started
+
+                               Install the package and run the server.
+
+                               ## Configuration
+
+                               Set the `SOLR_URL` environment variable.
+                               """;
+
+               // When
+               List<SolrInputDocument> documents = 
indexingDocumentCreator.createSchemalessDocumentsFromMarkdown(markdown);
+
+               // Then
+               assertThat(documents).hasSize(1);
+
+               SolrInputDocument doc = documents.getFirst();
+               assertThat(doc.getFieldValue("title")).isEqualTo("Getting 
Started");
+               
assertThat(doc.getFieldValues("headings")).containsExactly("Getting Started", 
"Configuration");
+
+               String content = (String) doc.getFieldValue("content");
+               assertThat(content).contains("Install the package and run the 
server.");
+               assertThat(content).contains("SOLR_URL");
+       }
+
+       @Test
+       void testPlainTextMarkdownWithoutHeadings() throws Exception {
+               // Given
+               String markdown = "Just a plain paragraph of text without any 
structure.";
+
+               // When
+               List<SolrInputDocument> documents = 
indexingDocumentCreator.createSchemalessDocumentsFromMarkdown(markdown);
+
+               // Then
+               assertThat(documents).hasSize(1);
+
+               SolrInputDocument doc = documents.getFirst();
+               assertThat(doc.getFieldValue("title")).isNull();
+               assertThat(doc.getFieldValues("headings")).isNull();
+               assertThat(doc.getFieldValue("content")).isEqualTo("Just a 
plain paragraph of text without any structure.");
+       }
+
+       @Test
+       void testFrontMatterFieldNamesAreSanitized() throws Exception {
+               // Given
+               String markdown = """
+                               ---
+                               Created-By: Jane Doe
+                               last.updated: 2026-01-01
+                               ---
+                               # Doc
+
+                               Body.
+                               """;
+
+               // When
+               List<SolrInputDocument> documents = 
indexingDocumentCreator.createSchemalessDocumentsFromMarkdown(markdown);
+
+               // Then
+               assertThat(documents).hasSize(1);
+
+               SolrInputDocument doc = documents.getFirst();
+               assertThat(doc.getFieldValue("created_by")).isEqualTo("Jane 
Doe");
+               
assertThat(doc.getFieldValue("last_updated")).isEqualTo("2026-01-01");
+       }
+
+       @Test
+       void testFrontMatterListValuesBecomeMultiValuedFields() throws 
Exception {
+               // Given
+               String markdown = """
+                               ---
+                               tags: [search, solr, mcp]
+                               ---
+                               # Tagged Document
+
+                               Body.
+                               """;
+
+               // When
+               List<SolrInputDocument> documents = 
indexingDocumentCreator.createSchemalessDocumentsFromMarkdown(markdown);
+
+               // Then
+               assertThat(documents).hasSize(1);
+               
assertThat(documents.getFirst().getFieldValues("tags")).containsExactly("search",
 "solr", "mcp");
+       }
+
+       @Test
+       void testFrontMatterBlockListValuesBecomeMultiValuedFields() throws 
Exception {
+               // Given
+               String markdown = """
+                               ---
+                               tags:
+                                 - search
+                                 - solr
+                               ---
+                               # Tagged Document
+
+                               Body.
+                               """;
+
+               // When
+               List<SolrInputDocument> documents = 
indexingDocumentCreator.createSchemalessDocumentsFromMarkdown(markdown);
+
+               // Then
+               assertThat(documents).hasSize(1);
+               
assertThat(documents.getFirst().getFieldValues("tags")).containsExactly("search",
 "solr");
+       }
+
+       @Test
+       void testFrontMatterIdIsUsedAsDocumentId() throws Exception {
+               // Given
+               String markdown = """
+                               ---
+                               id: doc-42
+                               ---
+                               # Identified Document
+
+                               Body.
+                               """;
+
+               // When
+               List<SolrInputDocument> documents = 
indexingDocumentCreator.createSchemalessDocumentsFromMarkdown(markdown);
+
+               // Then
+               assertThat(documents).hasSize(1);
+               
assertThat(documents.getFirst().getFieldValue("id")).isEqualTo("doc-42");
+       }
+
+       @Test
+       void testGeneratedIdIsStableForSameContent() throws Exception {
+               // Given
+               String markdown = "# Stable\n\nSame content, same id.";
+
+               // When
+               List<SolrInputDocument> first = 
indexingDocumentCreator.createSchemalessDocumentsFromMarkdown(markdown);
+               List<SolrInputDocument> second = 
indexingDocumentCreator.createSchemalessDocumentsFromMarkdown(markdown);
+               List<SolrInputDocument> other = indexingDocumentCreator
+                               .createSchemalessDocumentsFromMarkdown("# 
Different\n\nOther content.");
+
+               // Then: re-indexing the same markdown must overwrite the same 
document
+               Object firstId = first.getFirst().getFieldValue("id");
+               assertThat(firstId).isNotNull();
+               
assertThat(firstId).isEqualTo(second.getFirst().getFieldValue("id"));
+               
assertThat(firstId).isNotEqualTo(other.getFirst().getFieldValue("id"));
+       }
+
+       @Test
+       void testEmptyMarkdownThrowsException() {
+               assertThatThrownBy(() -> 
indexingDocumentCreator.createSchemalessDocumentsFromMarkdown(""))
+                               
.isInstanceOf(DocumentProcessingException.class).hasMessageContaining("cannot 
be null or empty");
+       }
+
+       @Test
+       void testMarkdownFormattingIsStrippedFromContent() throws Exception {
+               // Given
+               String markdown = """
+                               # Formatted
+
+                               Some **bold** and *italic* text with a 
[link](https://solr.apache.org) and `inline code`.
+                               """;
+
+               // When
+               List<SolrInputDocument> documents = 
indexingDocumentCreator.createSchemalessDocumentsFromMarkdown(markdown);
+
+               // Then
+               assertThat(documents).hasSize(1);
+
+               String content = (String) 
documents.getFirst().getFieldValue("content");
+               assertThat(content).contains("bold");
+               assertThat(content).contains("italic");
+               assertThat(content).contains("inline code");
+               assertThat(content).doesNotContain("**");
+               assertThat(content).doesNotContain("](");
+       }
+}

Reply via email to