Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,7 @@ Using a different client, or want STDIO/HTTP/Docker options? See the per-client
| Tool | Description |
|------|-------------|
| `search` | Full-text search with filtering, faceting, sorting, and pagination |
| `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 |
| `index-documents` | Index documents supplied inline into a collection; `format` selects `json`, `csv`, `xml` or `markdown` (front matter, title, headings and body extracted) |
| `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 |
Expand Down Expand Up @@ -127,7 +124,7 @@ Slash-command-style workflow templates that walk the assistant through a canonic
| `setup-collection` | `name`, `purpose` (optional) | Pick configset / shards / replication factor, create the collection, verify it |
| `view-schema` | `collection` | Read-only schema walkthrough |
| `design-schema` | `collection`, `datasetDescription`, `sampleDocument` (optional) | Choose field types and apply additive schema changes |
| `index-data` | `collection`, `format` (`json` / `csv` / `xml`), `sample` (optional) | Pick the right indexing tool and confirm the result |
| `index-data` | `collection`, `format` (`json` / `csv` / `xml` / `markdown`), `sample` (optional) | Verify the schema, call `index-documents` with the right format, and confirm the result |
| `search-collection` | `collection`, `question` | Translate a natural-language question into a Solr query |

### Completions
Expand Down
8 changes: 4 additions & 4 deletions THREAT_MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ it speaks MCP (JSON-RPC) to an AI client over one of two transports — **STDIO*
(streamable-HTTP; a network listener). On the other side it speaks SolrJ HTTP to
**one** backend Solr instance whose location and credentials the operator fixes
at startup via environment (`SOLR_URL`, optional `SOLR_USERNAME`/`SOLR_PASSWORD`).
It exposes eleven tools (search, three indexing formats, collection create/list/
It exposes nine tools (search, inline indexing, collection create/list/
stats/health, schema get/add-fields/add-field-types), two resources
(`solr://collections`, `solr://{collection}/schema`), and prompt/completion
helpers. It translates natural-language requests — as structured by the calling
Expand Down Expand Up @@ -245,7 +245,7 @@ trust table:
| `search` | `collection` | **yes** | used only as a path segment against the fixed `SOLR_URL` base; **cannot redirect to another host**. What a path reaches *within* that Solr is the backend's authorization call. *(maintainer — Q-collection.)* |
| `search` | `query` (`q`), `filterQueries` (`fq`) | **yes** | passed into `SolrQuery`; Solr query-parser semantics apply — Q-queryinj |
| `search` | `facetFields`, `sortClauses`, `start`, `rows` | **yes** | forwarded to Solr; `rows` unbounded? — Q-resource |
| `index-*` | `collection`, `json`/`csv`/`xml` body | **yes** | parsed then written to index; XML parser is XXE-hardened *(documented)* |
| `index-documents` | `collection`, `content`, `format` | **yes** | parsed then written to index; XML parser is XXE-hardened *(documented)* |
| `create-collection` | `name`, `configSet`, `numShards`, `replicationFactor` | **yes** | issues `CollectionAdminRequest.createCollection` to backend — Q-adminexposure |
| `add-fields` / `add-field-types` | `collection`, field/type defs | **yes** | additive schema change (existing fields cannot be modified per README) |
| config (startup only) | `SOLR_URL`, `SOLR_USERNAME`, `SOLR_PASSWORD` | **no — deployer config** | never wire from a tool argument *(documented)* |
Expand Down Expand Up @@ -315,12 +315,12 @@ Two adversaries are in scope; several are explicitly not.
6. **XML indexing is XXE-hardened.** `XmlDocumentCreator` builds a
`DocumentBuilderFactory` with secure processing on, DOCTYPE disallowed,
external general/parameter entities off, XInclude off, entity-expansion off.
*Violation:* an XXE/entity-expansion payload in an `index-xml-documents` body
*Violation:* an XXE/entity-expansion payload in an `index-documents` body with `format=xml`
reads a local file or hangs the parser. *Severity:* high. *(documented —
`XmlDocumentCreator.createSecureDocumentBuilderFactory`.)*
7. **Tool behaviour hints are advertised honestly.** Every tool carries MCP
annotations (`readOnlyHint` on the five read tools, `idempotentHint` on the
three index tools, `destructiveHint=false` on schema/create tools) so clients
index tool, `destructiveHint=false` on schema/create tools) so clients
can build approval UX. *Violation:* a tool that mutates state advertises
`readOnlyHint=true`. *Severity:* medium (client-UX safety). *(documented —
README; `@McpTool.McpAnnotations` on each service method.)*
Expand Down
153 changes: 85 additions & 68 deletions src/main/java/org/apache/solr/mcp/server/indexing/IndexingService.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import io.micrometer.observation.annotation.Observed;
import java.io.IOException;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.TreeSet;
import java.util.stream.Collectors;
Expand Down Expand Up @@ -148,14 +149,62 @@ public IndexingService(SolrClient solrClient, IndexingDocumentCreator indexingDo
this.indexingDocumentCreator = indexingDocumentCreator;
}

/**
* Indexes documents supplied inline as a string, selecting the parser by
* {@code format}. One tool with a format argument replaces four near-identical
* tool schemas in every session's catalog. The format is explicit rather than
* sniffed: inline payloads have no filename, and CSV and Markdown are both
* plain text with no safe distinguishing prefix.
*
* @param collection
* the name of the Solr collection to index into
* @param content
* the documents, as a string in the given format
* @param format
* one of {@code json}, {@code csv}, {@code xml}, {@code markdown}
* (alias {@code md}); case-insensitive
* @return a summary of how many documents were indexed and the field names as
* indexed
* @throws IllegalArgumentException
* if the format is missing or not one of the accepted values
* @throws IOException
* if there are I/O errors during Solr communication
* @throws SolrServerException
* if there are Solr-specific errors during indexing
* @throws ParserConfigurationException
* if the XML parser cannot be configured
* @throws SAXException
* if the XML content is malformed
*/
@PreAuthorize("isAuthenticated()")
@McpTool(name = "index-documents", annotations = @McpTool.McpAnnotations(idempotentHint = true), description = """
Index documents supplied inline into a Solr collection. Set format to json (an array of objects
or a single object), csv (first row is the header), xml (Solr <add><doc> or generic elements),
or markdown (one document; front matter, title, headings and body are extracted; supply a
stable 'id' in the YAML front matter). Only convert source content to markdown when it is not
already JSON, CSV or XML. Field names are sanitized for Solr compatibility (lowercased, special
characters replaced with underscores); the response lists the field names as indexed.
""")
public String indexDocuments(@McpToolParam(description = "Solr collection to index into") String collection,
@McpToolParam(description = "The documents, as a string in the given format") String content,
@McpToolParam(description = "Format of content: json, csv, xml or markdown (alias md)") String format)
throws IOException, SolrServerException, ParserConfigurationException, SAXException {
return switch (normalizeFormat(format)) {
case "json" -> indexJsonDocuments(collection, content);
case "csv" -> indexCsvDocuments(collection, content);
case "xml" -> indexXmlDocuments(collection, content);
default -> indexMarkdownDocuments(collection, content);
};
}

/**
* Indexes documents from a JSON string into a specified Solr collection.
*
* <p>
* This method serves as the primary entry point for document indexing
* operations and is exposed as an MCP tool for AI client interactions. It
* processes JSON data containing document arrays and indexes them using a
* schema-less approach.
* operations from Java; MCP clients use the {@code index-documents} tool with
* the matching {@code format}. It processes JSON data containing document
* arrays and indexes them using a schema-less approach.
*
* <p>
* <strong>Supported JSON Formats:</strong>
Expand Down Expand Up @@ -209,16 +258,7 @@ public IndexingService(SolrClient solrClient, IndexingDocumentCreator indexingDo
* @see IndexingDocumentCreator#createSchemalessDocumentsFromJson(String)
* @see #indexDocuments(String, List)
*/
@PreAuthorize("isAuthenticated()")
@McpTool(
name = "index-json-documents",
annotations = @McpTool.McpAnnotations(idempotentHint = true),
description = "Index documents from json String into Solr collection. Field names are"
+ " sanitized for Solr compatibility (lowercased, special characters replaced"
+ " with underscores); the response lists the field names as indexed")
public String indexJsonDocuments(@McpToolParam(description = "Solr collection to index into") String collection,
@McpToolParam(description = "JSON string containing documents to index") String json)
throws IOException, SolrServerException {
public String indexJsonDocuments(String collection, String json) throws IOException, SolrServerException {
List<SolrInputDocument> schemalessDoc = indexingDocumentCreator.createSchemalessDocumentsFromJson(json);
int successCount = indexDocuments(collection, schemalessDoc);
return "Successfully indexed " + successCount + " of " + schemalessDoc.size() + " documents into collection '"
Expand All @@ -230,9 +270,9 @@ public String indexJsonDocuments(@McpToolParam(description = "Solr collection to
*
* <p>
* This method serves as the primary entry point for CSV document indexing
* operations and is exposed as an MCP tool for AI client interactions. It
* processes CSV data with headers and indexes them using a schema-less
* approach.
* operations from Java; MCP clients use the {@code index-documents} tool with
* the matching {@code format}. It processes CSV data with headers and indexes
* them using a schema-less approach.
*
* <p>
* <strong>Supported CSV Formats:</strong>
Expand Down Expand Up @@ -284,16 +324,7 @@ public String indexJsonDocuments(@McpToolParam(description = "Solr collection to
* @see IndexingDocumentCreator#createSchemalessDocumentsFromCsv(String)
* @see #indexDocuments(String, List)
*/
@PreAuthorize("isAuthenticated()")
@McpTool(
name = "index-csv-documents",
annotations = @McpTool.McpAnnotations(idempotentHint = true),
description = "Index documents from CSV string into Solr collection. Column names are"
+ " sanitized for Solr compatibility (lowercased, special characters replaced"
+ " with underscores); the response lists the field names as indexed")
public String indexCsvDocuments(@McpToolParam(description = "Solr collection to index into") String collection,
@McpToolParam(description = "CSV string containing documents to index") String csv)
throws IOException, SolrServerException {
public String indexCsvDocuments(String collection, String csv) throws IOException, SolrServerException {
List<SolrInputDocument> schemalessDoc = indexingDocumentCreator.createSchemalessDocumentsFromCsv(csv);
int successCount = indexDocuments(collection, schemalessDoc);
return "Successfully indexed " + successCount + " of " + schemalessDoc.size() + " documents into collection '"
Expand All @@ -305,9 +336,9 @@ public String indexCsvDocuments(@McpToolParam(description = "Solr collection to
*
* <p>
* This method serves as the primary entry point for XML document indexing
* operations and is exposed as an MCP tool for AI client interactions. It
* processes XML data with nested elements and attributes, indexing them using a
* schema-less approach.
* operations from Java; MCP clients use the {@code index-documents} tool with
* the matching {@code format}. It processes XML data with nested elements and
* attributes, indexing them using a schema-less approach.
*
* <p>
* <strong>Supported XML Formats:</strong>
Expand Down Expand Up @@ -383,15 +414,7 @@ public String indexCsvDocuments(@McpToolParam(description = "Solr collection to
* @see IndexingDocumentCreator#createSchemalessDocumentsFromXml(String)
* @see #indexDocuments(String, List)
*/
@PreAuthorize("isAuthenticated()")
@McpTool(
name = "index-xml-documents",
annotations = @McpTool.McpAnnotations(idempotentHint = true),
description = "Index documents from XML string into Solr collection. Element names are"
+ " sanitized for Solr compatibility (lowercased, special characters replaced"
+ " with underscores); the response lists the field names as indexed")
public String indexXmlDocuments(@McpToolParam(description = "Solr collection to index into") String collection,
@McpToolParam(description = "XML string containing documents to index") String xml)
public String indexXmlDocuments(String collection, String xml)
throws ParserConfigurationException, SAXException, IOException, SolrServerException {
List<SolrInputDocument> schemalessDoc = indexingDocumentCreator.createSchemalessDocumentsFromXml(xml);
int successCount = indexDocuments(collection, schemalessDoc);
Expand All @@ -404,9 +427,10 @@ public String indexXmlDocuments(@McpToolParam(description = "Solr collection to
*
* <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.
* operations from Java; MCP clients use the {@code index-documents} tool with
* {@code format=markdown}. 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>
Expand Down Expand Up @@ -457,17 +481,7 @@ public String indexXmlDocuments(@McpToolParam(description = "Solr collection to
* @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 {
public String indexMarkdownDocuments(String collection, 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 '"
Expand Down Expand Up @@ -608,19 +622,21 @@ public int indexDocuments(String collection, List<SolrInputDocument> documents)
}

/**
* Maps an input-format keyword to the MCP tool and payload parameter for that
* format.
* Normalizes a user-supplied format keyword to the canonical value accepted by
* {@code index-documents}.
*
* @param format
* {@code json}, {@code csv}, {@code xml}, {@code markdown} or
* {@code md}, in any case, with surrounding whitespace ignored
* @return {@code json}, {@code csv}, {@code xml} or {@code markdown}
* @throws IllegalArgumentException
* if the format is null, blank or unrecognized
*/
private record IndexTool(String name, String paramName) {
}

private static IndexTool resolveIndexTool(String format) {
String normalized = (format == null) ? "" : format.trim().toLowerCase();
static String normalizeFormat(String format) {
String normalized = (format == null) ? "" : format.trim().toLowerCase(Locale.ROOT);
return switch (normalized) {
case "json" -> new IndexTool("index-json-documents", "json");
case "csv" -> new IndexTool("index-csv-documents", "csv");
case "xml" -> new IndexTool("index-xml-documents", "xml");
case "markdown", "md" -> new IndexTool("index-markdown-documents", "markdown");
case "json", "csv", "xml" -> normalized;
case "markdown", "md" -> "markdown";
default ->
throw new IllegalArgumentException("format must be one of json/csv/xml/markdown, got: " + format);
};
Expand Down Expand Up @@ -659,7 +675,7 @@ public String indexDataPrompt(
name = "sample",
description = "Optional small sample of the input document(s) to ground field-shape decisions",
required = false) String sample) {
IndexTool indexTool = resolveIndexTool(format);
String normalizedFormat = normalizeFormat(format);
String sampleSection = PromptText.optionalCodeBlock(sample, "Sample input:",
"No sample was provided. If the user has not pasted the documents yet, ask for them (or a representative subset) before indexing.");
return """
Expand All @@ -677,12 +693,13 @@ public String indexDataPrompt(
%s

3. Index the documents.
- Call `%s` with `collection=%s` and `%s=<the document payload>`.
- Call `index-documents` with `collection=%s`, `format=%s` and
`content=<the document payload>`.
- The tool batches internally and commits at the end. The return value is the count
of successfully indexed documents.
- On error, read the message carefully: an "unknown field" error means the schema is
missing a field — go back to step 1 and run `design-schema`. A parse error means
the input format does not match the chosen tool — fix the payload and retry.
the input does not match `format` — fix the payload or the format and retry.

4. Verify the count.
- Call `check-health` on `%s` and confirm the reported doc count increased by the
Expand All @@ -691,7 +708,7 @@ public String indexDataPrompt(

Next step suggestion: once data is indexed, the `search-collection` prompt drives
searching it.
""".formatted(indexTool.paramName(), collection, collection, sampleSection, indexTool.name(),
collection, indexTool.paramName(), collection);
""".formatted(normalizedFormat, collection, collection, sampleSection, collection, normalizedFormat,
collection);
}
}
Loading