diff --git a/AGENTS.md b/AGENTS.md index 64a38f86..4cf455ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,6 +112,15 @@ Four service classes expose MCP tools via `@McpTool` annotations: - **CollectionService** (`collection/`) - List collections, get stats, health checks - **SchemaService** (`schema/`) - Schema introspection and additive modification (add-fields, add-field-types) +### Solr Agent Skill + +`src/main/resources/skills/solr/` holds an Agent Skill (`SKILL.md` + `references/`) about this +server's tools, not about Solr: the Solr Reference Guide covers Solr itself. Keep it to server +behaviour that the tool descriptions, prompts and server instructions do not already say. +`SolrSkillFormatTest` enforces the Agent Skills format and the SEP-2640 size limits. When tool +behaviour changes (error messages, response wording, id handling), update the skill in the same PR. +Serving it over MCP is tracked in #280. + ### Document Creators and pass-throughs Only the formats Solr cannot parse itself go through server-side document creators diff --git a/README.md b/README.md index c199e4be..19675b87 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,18 @@ Slash-command-style workflow templates that walk the assistant through a canonic | `index-data` | `collection`, `format` (`json` / `csv` / `xml`), `sample` (optional) | Pick the right indexing tool and confirm the result | | `search-collection` | `collection`, `question` | Translate a natural-language question into a Solr query | +### Skill + +`src/main/resources/skills/solr/` is an [Agent Skill](https://agentskills.io/specification) for this +server's tools: what to know before writing data (shared `_default` configset, document ids, how field +names and values are stored), how the indexing tools respond, how to read the server's errors, and +what differs across Solr versions. It does not teach Solr itself; the Solr Reference Guide covers that. + +To use it today, copy the directory into your client's skills location, for example +`cp -r src/main/resources/skills/solr ~/.claude/skills/`. Serving it to clients over MCP itself, once +the protocol and SDK support the Skills extension, is tracked in +[#280](https://github.com/apache/solr-mcp/issues/280). + ### Completions The server implements MCP argument autocompletion, so clients can suggest valid values as you type: diff --git a/docs/FAQ.md b/docs/FAQ.md index c56939db..d02655da 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -57,9 +57,13 @@ re-derive it imperfectly every call: Keep the server for deterministic, security-sensitive, multi-step operations and for non-Claude clients (it's an Apache incubating project for *any* MCP -client). Add a thin Solr skill for query and faceting know-how. The skill -makes the agent better at *using* Solr; the server makes the dangerous parts -*safe and repeatable*. +client). Pair it with a thin skill. The repo ships one +(`src/main/resources/skills/solr/`) that covers what is specific to this +server's tools; Solr itself (query syntax, analyzers, schema design) is what the +[Solr Reference Guide](https://solr.apache.org/guide/solr/latest/) is for, and +an LLM can reach it through its own knowledge, web search or a docs-lookup tool. +The skill makes the agent better at *using this server*; the server makes the +dangerous parts *safe and repeatable*. ## Doesn't an MCP server cost more context than a skill? diff --git a/src/main/resources/skills/solr/SKILL.md b/src/main/resources/skills/solr/SKILL.md new file mode 100644 index 00000000..8cb3c5f2 --- /dev/null +++ b/src/main/resources/skills/solr/SKILL.md @@ -0,0 +1,66 @@ +--- +name: solr +description: Use when searching, indexing, or managing Apache Solr collections through the Solr MCP server (tools such as search, index-json-documents, get-schema, add-fields, create-collection, list-collections), or when the user asks about their Solr data, collections, or schema. Explains how the server's tools behave, what to know before writing data, how their results and errors read, and where results depend on the Solr version. It does not teach Solr itself; for query syntax, analysis, and schema design use the Solr Reference Guide. +license: Apache-2.0 +metadata: + version: "1.0" +--- + +# Solr MCP server + +This skill covers what is specific to this server's tools. It is not a Solr tutorial. For anything +about Solr itself (query parsers, analyzers, field types, faceting, relevance), consult the +[Solr Reference Guide](https://solr.apache.org/guide/solr/latest/) through your own knowledge, +web search, or a documentation-lookup tool, and match the guide version to the Solr in use. + +## Which tool + +| Task | Tool | +|---|---| +| See what collections exist | `list-collections` (also the `solr://collections` resource) | +| See fields and types | `get-schema` (also the `solr://{collection}/schema` resource) | +| Search, filter, facet, sort, page | `search` | +| Index JSON / CSV / Solr update XML / Markdown | `index-json-documents`, `index-csv-documents`, `index-xml-documents`, `index-markdown-documents` | +| Add fields or field types | `add-fields`, `add-field-types` | +| Create a collection | `create-collection` | +| Point a name at collections | `list-aliases`, `create-alias`, `delete-alias` | +| Size, query timings, cache and handler metrics | `get-collection-stats` | +| Is the collection reachable | `check-health` | + +Pick the index tool by input format; do not convert JSON or CSV to Markdown to index it. + +## Before you write data + +1. **Collections share a schema by default.** `create-collection` uses the `_default` configset + unless told otherwise, and collections created that way share one schema. A field added with + `add-fields`, or created by Solr while indexing, on one collection shows up on the others, and a + later `add-fields` for the same name fails because the field already exists. Run `get-schema` + on a collection before assuming it is empty, and choose field names and types with the whole + cluster in mind. +2. **Give every document a stable `id`.** Without one, Solr's default update chain generates a random + id for JSON, CSV and XML documents, so sending the same payload twice creates duplicates. + Markdown documents without an `id` get one derived from their text: identical text replaces + itself, edited text becomes a new document. +3. **Field names and values are not always stored as sent.** Nested JSON objects are flattened with + underscores, and objects or arrays nested inside a JSON array are dropped. Fields that Solr + creates while indexing hold arrays when read back. Run `get-schema` before querying fields you + did not create. Details are in `references/indexing.md`. +4. **Each index call commits.** The documents are searchable when the call returns; there is no + commit tool and none is needed. + +## Reading results + +- `check-health` reports an unhealthy or unknown collection in its result, not as a tool error. +- `get-collection-stats` can return `null` for `cacheStats` and `handlerStats`. That is expected. +- A tool call that fails with a protocol-level error and no message usually means Solr could not be + reached or is misconfigured, not that the request was wrong. + +## References + +Open a reference only when the task needs it. + +- `references/indexing.md` - what the `index-*` tools do to your data, how responses read, partial + failures, and field-type surprises. +- `references/troubleshooting.md` - the server's error messages, what each means, and the next call. +- `references/solr-versions.md` - what differs across the supported Solr versions (9.4 to 10) as + seen through the tools. diff --git a/src/main/resources/skills/solr/references/indexing.md b/src/main/resources/skills/solr/references/indexing.md new file mode 100644 index 00000000..6b499b64 --- /dev/null +++ b/src/main/resources/skills/solr/references/indexing.md @@ -0,0 +1,68 @@ +# What the index tools do to your data + +The four `index-*` tools send the payload to Solr in one update request that also commits, so the +documents are searchable when the tool returns. For what fields, types and analyzers mean in Solr, +see the Solr Reference Guide. + +## Ids and re-sending + +- JSON, CSV and XML: whatever `id` you give is used. Without one, Solr's default update chain + assigns a random id, so re-sending creates new documents instead of replacing old ones. +- Markdown: a document without an `id` gets one derived from its text. The same text replaces + itself; an edited document is a new one. Put a stable `id` in the front matter to update in place. + +## JSON + +- Each object becomes one document. Field names are used as sent. +- Nested objects are flattened with underscores: `{"author": {"name": "X"}}` is indexed as + `author_name`. +- Inside an array, only scalar items are kept. Objects and arrays nested in an array are dropped + without a warning, and an array left with no scalars produces no field. +- The response ends with the indexed field names (at most 50, then a count of the rest). The list is + built from every submitted document, including any that Solr then rejected, so a listed name may + not exist in the schema. + +## CSV and XML + +Forwarded to Solr's own update handlers; the server does not parse the content. + +- `index-xml-documents` takes Solr `` blocks of `` elements only: send + `` content and nothing else. +- Solr's response carries no document count, so none is reported. Confirm with `search` or + `get-collection-stats`. +- When Solr rejects a payload, fix it and re-send. The server does not retry these individually. + +## Markdown + +- A single string that begins with `---` and contains several front-matter blocks, each preceded by a + blank line, is split into several documents. The `M` in the response counts documents after + splitting, not array elements. +- An empty or blank element fails the whole call with `Markdown input cannot be null or empty`. +- Front matter values are indexed as text, whatever YAML would infer: `2024` and `true` arrive as + strings. Sequences become multi-valued fields and nested mappings are flattened with underscores. +- Extracted fields: `title` (front matter `title`, else the first level-1 heading), `headings` + (every heading), `content` (body without front matter). +- Unlike the JSON tool, the response does not list the indexed field names. + +## Reading the response + +- JSON and Markdown: `Successfully indexed N of M documents into collection '...'`. +- CSV and XML: `Solr accepted the ... and committed it (status S, T ms)`. + +## Partial failures (JSON and Markdown) + +If Solr rejects the batch with a 400 (usually an unknown field or a value of the wrong type), the +server retries the documents one at a time so the valid ones are indexed, then commits. The response +gives only the count; it does not say which documents failed or why. If `N` is less than `M`, run +`get-schema`, compare the failing fields with the schema, fix the data or add the missing field, and +re-send. Documents without an `id` that were already accepted before the failure may be indexed +again on the retry, so unique ids matter most here. Failures other than a 400 (unknown collection, +authorisation, Solr unreachable) are not retried and are returned as errors. + +## Fields you did not create + +On the `_default` configset, Solr creates a field the first time it sees it and picks the type from +that first value. Fields created this way hold arrays when read back. A wrong guess cannot be +changed with this server, which cannot alter existing fields, and because collections created with +defaults share one schema (see `SKILL.md`), it affects every collection on that configset. When a +field's type matters, call `add-fields` first, then index. diff --git a/src/main/resources/skills/solr/references/solr-versions.md b/src/main/resources/skills/solr/references/solr-versions.md new file mode 100644 index 00000000..11ee4ef5 --- /dev/null +++ b/src/main/resources/skills/solr/references/solr-versions.md @@ -0,0 +1,28 @@ +# Solr versions as seen through the tools + +The server supports Solr 9.4 to 10. No tool reports the Solr version, so ask the user or the operator +if it matters. The tools, parameters and response shapes are the same across the range, except for +the metrics figures below. + +## `get-collection-stats`: `cacheStats` and `handlerStats` + +These two figures can be `null`, and the rest of the result (index statistics and query timings) is +still filled in. Report them as unavailable, not as a fault. They are `null` when: + +- **The Solr is version 10.** Solr 10's metrics endpoint does not return the JSON the server reads, + so these figures are never available there. +- **The node the server talks to holds no replica of the collection.** The figures are read from + that node. +- **No cache or handler activity has been recorded yet.** Run a search and ask again. + +## Not version-dependent + +- `search`, the four `index-*` tools, `get-schema`, `add-fields`, `add-field-types`, + `create-collection`, and the alias tools behave the same on every supported version. +- The server requires SolrCloud (collection listing uses the Collections API); standalone Solr is not + supported on any version. + +## Version-specific Solr behaviour + +How a Solr feature differs between versions (a query parser, a field type, a Schema API command) is +Reference Guide material. Read the guide for the version in use rather than assuming. diff --git a/src/main/resources/skills/solr/references/troubleshooting.md b/src/main/resources/skills/solr/references/troubleshooting.md new file mode 100644 index 00000000..5a6b0a17 --- /dev/null +++ b/src/main/resources/skills/solr/references/troubleshooting.md @@ -0,0 +1,50 @@ +# Troubleshooting tool errors + +The server rewrites most Solr failures into short messages that say what to do next. Match the +message and make the call it names. + +## No message at all + +A call that fails with a protocol-level error and no text, from `search`, the `index-*` tools or +`get-schema`, is a failure to reach Solr or to talk to it (connection refused, I/O error), not a +problem with your arguments. Retry once; if it persists, tell the user the operator must check the +Solr URL, network and credentials. The `Search failed: unable to communicate with Solr` message is +written for this case but may not reach you. + +## `search` + +| Message starts with | Meaning | Next call | +|---|---|---| +| `Search failed: the collection was not found` | The `collection` does not exist | `list-collections`, then retry with a listed name | +| `Search failed: a referenced field is not defined` | `query`, `filterQueries`, `facetFields` or `sortClauses` names a field the schema lacks | `get-schema`; fields Solr created while indexing may carry suffixes such as `name_s` | +| `Search failed: Solr could not parse the query or filters` | Lucene syntax error, or a bad `{!...}` local param | Quote or escape special characters; check local params | +| `Search failed: Solr rejected the request` | Another 400 | Re-check syntax, filters, facets, sort, pagination; `get-schema` to verify fields | +| `Search failed: Solr denied access` | Solr returned 401 or 403 | Not fixable from the tool; the operator must check Solr credentials and permissions | +| `Search failed: unable to process the Solr response` | Any unexpected failure reading the response | If `facetFields` were set, `get-schema` to check them or retry without; else tell the user the operator must check server logs | +| `Search failed: Solr could not complete the request` | Any other Solr error | Retry later; if it persists, the operator must check Solr health and logs | + +## `get-collection-stats`, `check-health` + +- `Collection not found: . Hint: call list-collections ...`: use a name from `list-collections`. +- Both accept a shard-style name such as `films_shard1_replica_n1` and resolve it to the collection. +- `check-health` does not raise an error for a down or unknown collection; it returns an unhealthy + status with the failure message. Read the result. +- `get-collection-stats` returns `null` for `cacheStats` and `handlerStats` in several situations. + This is expected, not an error (see `references/solr-versions.md`). + +## Indexing + +- `Successfully indexed N of M` with N < M: some documents were rejected individually. See + "Partial failures" in `references/indexing.md`. +- `XML input must be a Solr block containing elements ...`: the payload is not a Solr + `` block. Send documents only. +- `Failed to parse XML document`: the XML is malformed, or carries a DOCTYPE, which is refused. +- `Markdown input cannot be null or empty`: one of the array elements is blank. +- A CSV or XML payload that Solr rejects returns Solr's error text. Fix the payload and re-send. + +## Schema and collections + +- `add-fields` fails with an "already exists" message when the field is already in the schema, + including when it was added or guessed through another collection on the same configset. +- `Each field definition requires a non-empty string 'name'`: a field map has no `name`. +- `Collection name must not be blank`, `Alias name must not be blank`: an argument was empty. diff --git a/src/test/java/org/apache/solr/mcp/server/skill/SolrSkillFormatTest.java b/src/test/java/org/apache/solr/mcp/server/skill/SolrSkillFormatTest.java new file mode 100644 index 00000000..be5872b6 --- /dev/null +++ b/src/test/java/org/apache/solr/mcp/server/skill/SolrSkillFormatTest.java @@ -0,0 +1,157 @@ +/* + * 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.skill; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.Yaml; + +/** + * Pins the bundled Solr skill ({@code src/main/resources/skills/solr}) to the + * Agent Skills format and to the limits SEP-2640 puts on a skill served over + * MCP, so a malformed skill fails the build instead of a client. + * + *

+ * Rules checked, from the Agent Skills specification: the frontmatter holds + * only the specified keys; {@code name} is 1-64 characters of lowercase + * letters, digits and single hyphens and equals the directory name; + * {@code description} is 1-1024 characters; {@code compatibility} is at most + * 500; {@code metadata} maps strings to strings; {@code SKILL.md} stays under + * 500 lines and roughly 5000 tokens; references sit directly under + * {@code references/}, every one is named in {@code SKILL.md}, and every + * relative Markdown link in the skill resolves to a file. The skill carries no + * {@code scripts/} directory, because hosts must not run code from an MCP + * server without explicit approval. + */ +class SolrSkillFormatTest { + + private static final Path SKILL_DIR = Path.of("src/main/resources/skills/solr"); + private static final Path SKILL_MD = SKILL_DIR.resolve("SKILL.md"); + private static final Path REFERENCES = SKILL_DIR.resolve("references"); + + private static final Set SPEC_KEYS = Set.of("name", "description", "license", "compatibility", "metadata", + "allowed-tools"); + private static final Pattern NAME = Pattern.compile("[a-z0-9]+(-[a-z0-9]+)*"); + private static final Pattern RELATIVE_LINK = Pattern.compile("\\]\\((?!https?:|#|mailto:)([^)#\\s]+)"); + + private static final int MAX_FILES = 512; + private static final long MAX_BYTES = 16L * 1024 * 1024; + private static final int APPROX_CHARS_PER_TOKEN = 4; + private static final int MAX_BODY_TOKENS = 5000; + + @Test + void frontmatterFollowsTheAgentSkillsSpecification() throws IOException { + Map frontmatter = frontmatter(read(SKILL_MD)); + + assertThat(frontmatter.keySet()).isSubsetOf(SPEC_KEYS); + assertThat(frontmatter.get("name")).isInstanceOf(String.class); + assertThat((String) frontmatter.get("name")).hasSizeBetween(1, 64).matches(NAME) + .isEqualTo(SKILL_DIR.getFileName().toString()); + assertThat(frontmatter.get("description")).isInstanceOf(String.class); + assertThat((String) frontmatter.get("description")).isNotBlank().hasSizeLessThanOrEqualTo(1024); + + if (frontmatter.containsKey("compatibility")) { + assertThat((String) frontmatter.get("compatibility")).hasSizeBetween(1, 500); + } + if (frontmatter.containsKey("metadata")) { + assertThat(frontmatter.get("metadata")).isInstanceOf(Map.class); + Map metadata = (Map) frontmatter.get("metadata"); + assertThat(metadata.keySet()).allSatisfy(k -> assertThat(k).isInstanceOf(String.class)); + assertThat(metadata.values()).allSatisfy(v -> assertThat(v).isInstanceOf(String.class)); + } + } + + @Test + void skillMdStaysWithinTheLineAndTokenBudget() throws IOException { + String skill = read(SKILL_MD); + + assertThat(skill.lines().count()).isLessThan(500); + assertThat(skill.length() / APPROX_CHARS_PER_TOKEN).isLessThan(MAX_BODY_TOKENS); + } + + @Test + void everyReferenceIsNamedInSkillMd() throws IOException { + String skill = read(SKILL_MD); + + try (Stream files = Files.list(REFERENCES)) { + List references = files.map(p -> "references/" + p.getFileName()).toList(); + assertThat(references).isNotEmpty(); + assertThat(references).allSatisfy(r -> assertThat(skill).contains(r)); + } + } + + @Test + void everyRelativeMarkdownLinkResolvesFromTheSkillRoot() throws IOException { + try (Stream files = Files.walk(SKILL_DIR)) { + for (Path file : files.filter(p -> p.toString().endsWith(".md")).toList()) { + var matcher = RELATIVE_LINK.matcher(read(file)); + while (matcher.find()) { + assertThat(SKILL_DIR.resolve(matcher.group(1))).as("link %s in %s", matcher.group(1), file) + .isRegularFile(); + } + } + } + } + + @Test + void referencesSitDirectlyUnderReferencesAndNothingIsExecutable() throws IOException { + assertThat(SKILL_DIR.resolve("scripts")).doesNotExist(); + try (Stream files = Files.walk(REFERENCES)) { + assertThat(files.filter(p -> !p.equals(REFERENCES))).allSatisfy(p -> { + assertThat(p).isRegularFile(); + assertThat(p.getParent()).isEqualTo(REFERENCES); + }); + } + } + + @Test + void skillFitsTheSep2640Limits() throws IOException { + try (Stream files = Files.walk(SKILL_DIR)) { + List all = files.filter(Files::isRegularFile).toList(); + long bytes = 0; + for (Path p : all) { + bytes += Files.size(p); + } + assertThat(all).hasSizeLessThanOrEqualTo(MAX_FILES); + assertThat(bytes).isLessThanOrEqualTo(MAX_BYTES); + } + } + + private static String read(Path path) throws IOException { + return Files.readString(path).replace("\r\n", "\n"); + } + + private static Map frontmatter(String markdown) { + assertThat(markdown).startsWith("---\n"); + int end = markdown.indexOf("\n---\n", 3); + assertThat(end).as("closing frontmatter delimiter").isPositive(); + Object parsed = new Yaml().load(markdown.substring(4, end)); + assertThat(parsed).isInstanceOf(Map.class); + @SuppressWarnings("unchecked") + Map map = (Map) parsed; + return map; + } +}