opensolr-mcp 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- opensolr_mcp-0.1.0/LICENSE +21 -0
- opensolr_mcp-0.1.0/PKG-INFO +88 -0
- opensolr_mcp-0.1.0/README.md +70 -0
- opensolr_mcp-0.1.0/opensolr_mcp/__init__.py +5 -0
- opensolr_mcp-0.1.0/opensolr_mcp/client.py +243 -0
- opensolr_mcp-0.1.0/opensolr_mcp/server.py +213 -0
- opensolr_mcp-0.1.0/opensolr_mcp.egg-info/PKG-INFO +88 -0
- opensolr_mcp-0.1.0/opensolr_mcp.egg-info/SOURCES.txt +12 -0
- opensolr_mcp-0.1.0/opensolr_mcp.egg-info/dependency_links.txt +1 -0
- opensolr_mcp-0.1.0/opensolr_mcp.egg-info/entry_points.txt +2 -0
- opensolr_mcp-0.1.0/opensolr_mcp.egg-info/requires.txt +2 -0
- opensolr_mcp-0.1.0/opensolr_mcp.egg-info/top_level.txt +1 -0
- opensolr_mcp-0.1.0/pyproject.toml +31 -0
- opensolr_mcp-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Opensolr SRL
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: opensolr-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for Opensolr — managed Apache Solr with hybrid BM25+kNN search, server-side embeddings, and RAG answers as agent tools
|
|
5
|
+
Author-email: Opensolr <support@opensolr.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://opensolr.com/langchain
|
|
8
|
+
Project-URL: Repository, https://github.com/phpcip/opensolr-mcp
|
|
9
|
+
Keywords: mcp,model-context-protocol,opensolr,solr,hybrid-search,rag,agents,vector
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Requires-Dist: mcp>=1.2.0
|
|
16
|
+
Requires-Dist: httpx>=0.25.0
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# opensolr-mcp
|
|
20
|
+
|
|
21
|
+
MCP (Model Context Protocol) server for [Opensolr](https://opensolr.com) —
|
|
22
|
+
gives any AI agent **managed Apache Solr search** as tools: hybrid
|
|
23
|
+
(BM25 + kNN) retrieval, server-side GPU embeddings, document indexing, and
|
|
24
|
+
grounded RAG answers.
|
|
25
|
+
|
|
26
|
+
No embedding model to configure. No vector database to run. One API key.
|
|
27
|
+
|
|
28
|
+
## Tools
|
|
29
|
+
|
|
30
|
+
| Tool | What it does |
|
|
31
|
+
|---|---|
|
|
32
|
+
| `opensolr_search` | Hybrid (keyword + semantic) or pure semantic search, with Solr filters |
|
|
33
|
+
| `opensolr_ai_answer` | Grounded RAG answer generated only from your indexed content |
|
|
34
|
+
| `opensolr_add_documents` | Index plain text + metadata (embedded server-side) |
|
|
35
|
+
| `opensolr_delete_documents` | Remove documents by id |
|
|
36
|
+
| `opensolr_list_indexes` / `opensolr_index_info` | Inspect the account's indexes |
|
|
37
|
+
| `opensolr_create_index` | Provision a vector-enabled index (`us`, `de`, `fi`) |
|
|
38
|
+
| `opensolr_vector_regions` | Live list of vector-enabled regions |
|
|
39
|
+
|
|
40
|
+
## Setup
|
|
41
|
+
|
|
42
|
+
Get a free Opensolr account (15-day trial, no card) at
|
|
43
|
+
[opensolr.com/register](https://opensolr.com/register) and copy your API key
|
|
44
|
+
from **Account**.
|
|
45
|
+
|
|
46
|
+
### Claude Desktop / Claude Code
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{
|
|
50
|
+
"mcpServers": {
|
|
51
|
+
"opensolr": {
|
|
52
|
+
"command": "uvx",
|
|
53
|
+
"args": ["opensolr-mcp"],
|
|
54
|
+
"env": {
|
|
55
|
+
"OPENSOLR_EMAIL": "you@example.com",
|
|
56
|
+
"OPENSOLR_API_KEY": "YOUR_OPENSOLR_API_KEY"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Cursor / Windsurf / any MCP client
|
|
64
|
+
|
|
65
|
+
Same shape — stdio transport, command `uvx opensolr-mcp` (or
|
|
66
|
+
`pipx run opensolr-mcp`), with `OPENSOLR_EMAIL` and `OPENSOLR_API_KEY` in env.
|
|
67
|
+
|
|
68
|
+
## Example agent session
|
|
69
|
+
|
|
70
|
+
> **You:** Index our FAQ answers, then find everything about refunds.
|
|
71
|
+
>
|
|
72
|
+
> The agent calls `opensolr_add_documents(index="faq__dense", texts=[...])`,
|
|
73
|
+
> then `opensolr_search(index="faq__dense", query="refund policy", hybrid=True)`
|
|
74
|
+
> — BM25 catches the exact word "refund", kNN catches "giving customers their
|
|
75
|
+
> money back", and the scores fuse per document.
|
|
76
|
+
|
|
77
|
+
## Notes
|
|
78
|
+
|
|
79
|
+
- Vector-enabled indexes run on Opensolr's Solr 9.x environments — currently
|
|
80
|
+
`us` (Chicago), `de` (Germany), `fi` (Finland), fetched live via
|
|
81
|
+
`opensolr_vector_regions`. Additional dedicated regions can be deployed on
|
|
82
|
+
request (paid add-on): [support@opensolr.com](mailto:support@opensolr.com).
|
|
83
|
+
- Every index is also plain Apache Solr with the native `/select` API —
|
|
84
|
+
nothing is locked behind the tools.
|
|
85
|
+
- Python sibling for LangChain: [`langchain-opensolr`](https://pypi.org/project/langchain-opensolr/) ·
|
|
86
|
+
Product page: [opensolr.com/langchain](https://opensolr.com/langchain)
|
|
87
|
+
|
|
88
|
+
MIT license.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# opensolr-mcp
|
|
2
|
+
|
|
3
|
+
MCP (Model Context Protocol) server for [Opensolr](https://opensolr.com) —
|
|
4
|
+
gives any AI agent **managed Apache Solr search** as tools: hybrid
|
|
5
|
+
(BM25 + kNN) retrieval, server-side GPU embeddings, document indexing, and
|
|
6
|
+
grounded RAG answers.
|
|
7
|
+
|
|
8
|
+
No embedding model to configure. No vector database to run. One API key.
|
|
9
|
+
|
|
10
|
+
## Tools
|
|
11
|
+
|
|
12
|
+
| Tool | What it does |
|
|
13
|
+
|---|---|
|
|
14
|
+
| `opensolr_search` | Hybrid (keyword + semantic) or pure semantic search, with Solr filters |
|
|
15
|
+
| `opensolr_ai_answer` | Grounded RAG answer generated only from your indexed content |
|
|
16
|
+
| `opensolr_add_documents` | Index plain text + metadata (embedded server-side) |
|
|
17
|
+
| `opensolr_delete_documents` | Remove documents by id |
|
|
18
|
+
| `opensolr_list_indexes` / `opensolr_index_info` | Inspect the account's indexes |
|
|
19
|
+
| `opensolr_create_index` | Provision a vector-enabled index (`us`, `de`, `fi`) |
|
|
20
|
+
| `opensolr_vector_regions` | Live list of vector-enabled regions |
|
|
21
|
+
|
|
22
|
+
## Setup
|
|
23
|
+
|
|
24
|
+
Get a free Opensolr account (15-day trial, no card) at
|
|
25
|
+
[opensolr.com/register](https://opensolr.com/register) and copy your API key
|
|
26
|
+
from **Account**.
|
|
27
|
+
|
|
28
|
+
### Claude Desktop / Claude Code
|
|
29
|
+
|
|
30
|
+
```json
|
|
31
|
+
{
|
|
32
|
+
"mcpServers": {
|
|
33
|
+
"opensolr": {
|
|
34
|
+
"command": "uvx",
|
|
35
|
+
"args": ["opensolr-mcp"],
|
|
36
|
+
"env": {
|
|
37
|
+
"OPENSOLR_EMAIL": "you@example.com",
|
|
38
|
+
"OPENSOLR_API_KEY": "YOUR_OPENSOLR_API_KEY"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Cursor / Windsurf / any MCP client
|
|
46
|
+
|
|
47
|
+
Same shape — stdio transport, command `uvx opensolr-mcp` (or
|
|
48
|
+
`pipx run opensolr-mcp`), with `OPENSOLR_EMAIL` and `OPENSOLR_API_KEY` in env.
|
|
49
|
+
|
|
50
|
+
## Example agent session
|
|
51
|
+
|
|
52
|
+
> **You:** Index our FAQ answers, then find everything about refunds.
|
|
53
|
+
>
|
|
54
|
+
> The agent calls `opensolr_add_documents(index="faq__dense", texts=[...])`,
|
|
55
|
+
> then `opensolr_search(index="faq__dense", query="refund policy", hybrid=True)`
|
|
56
|
+
> — BM25 catches the exact word "refund", kNN catches "giving customers their
|
|
57
|
+
> money back", and the scores fuse per document.
|
|
58
|
+
|
|
59
|
+
## Notes
|
|
60
|
+
|
|
61
|
+
- Vector-enabled indexes run on Opensolr's Solr 9.x environments — currently
|
|
62
|
+
`us` (Chicago), `de` (Germany), `fi` (Finland), fetched live via
|
|
63
|
+
`opensolr_vector_regions`. Additional dedicated regions can be deployed on
|
|
64
|
+
request (paid add-on): [support@opensolr.com](mailto:support@opensolr.com).
|
|
65
|
+
- Every index is also plain Apache Solr with the native `/select` API —
|
|
66
|
+
nothing is locked behind the tools.
|
|
67
|
+
- Python sibling for LangChain: [`langchain-opensolr`](https://pypi.org/project/langchain-opensolr/) ·
|
|
68
|
+
Product page: [opensolr.com/langchain](https://opensolr.com/langchain)
|
|
69
|
+
|
|
70
|
+
MIT license.
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Thin REST client for the Opensolr platform APIs.
|
|
2
|
+
|
|
3
|
+
Two base URLs, by platform design:
|
|
4
|
+
- Management API (index list/info/create): https://opensolr.com/solr_manager/api
|
|
5
|
+
- AI API (embed, batch_embed, embed_and_search, ai_summary): https://api.opensolr.com/solr_manager/api
|
|
6
|
+
|
|
7
|
+
Direct Solr access (select/update) goes to the index's own host, resolved via
|
|
8
|
+
``get_core_info`` (``connection_url`` + HTTP basic auth).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
MGMT_BASE = "https://opensolr.com/solr_manager/api"
|
|
19
|
+
AI_BASE = "https://api.opensolr.com/solr_manager/api"
|
|
20
|
+
|
|
21
|
+
#: Convenience aliases for Opensolr's vector-enabled environments. The
|
|
22
|
+
#: authoritative list is served live by the platform (``vector_regions``
|
|
23
|
+
#: endpoint) — new regions become valid automatically, and additional
|
|
24
|
+
#: dedicated regions can be deployed on request (paid): support@opensolr.com.
|
|
25
|
+
VECTOR_LOCATIONS: Dict[str, str] = {
|
|
26
|
+
"us": "CHICAGO-96",
|
|
27
|
+
"de": "DE-SOLR-9",
|
|
28
|
+
"fi": "FINLAND9",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def resolve_location(location: str) -> str:
|
|
33
|
+
"""Map a friendly alias ("us"/"de"/"fi") to its environment identifier.
|
|
34
|
+
|
|
35
|
+
Unknown values pass through unchanged — validity is decided against the
|
|
36
|
+
live ``vector_regions`` list (or, ultimately, by the server), so newly
|
|
37
|
+
deployed vector regions work without a package upgrade.
|
|
38
|
+
"""
|
|
39
|
+
return VECTOR_LOCATIONS.get(location.strip().lower(), location.strip())
|
|
40
|
+
|
|
41
|
+
#: Server-side limit for one batch_embed call.
|
|
42
|
+
BATCH_EMBED_MAX = 50
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class OpensolrError(RuntimeError):
|
|
46
|
+
"""Raised when an Opensolr API call fails."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class OpensolrClient:
|
|
50
|
+
"""Authenticated client for Opensolr management + AI endpoints.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
email: Opensolr account email.
|
|
54
|
+
api_key: Opensolr API key (Account > API in the control panel).
|
|
55
|
+
timeout: Per-request timeout in seconds. Embedding calls run on GPU
|
|
56
|
+
infrastructure and are usually fast, but cold starts happen.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(self, email: str, api_key: str, timeout: float = 120.0) -> None:
|
|
60
|
+
self.email = email
|
|
61
|
+
self.api_key = api_key
|
|
62
|
+
self._http = httpx.Client(timeout=timeout, follow_redirects=True)
|
|
63
|
+
self._core_info_cache: Dict[str, Dict[str, Any]] = {}
|
|
64
|
+
|
|
65
|
+
# ------------------------------------------------------------------ #
|
|
66
|
+
# low level #
|
|
67
|
+
# ------------------------------------------------------------------ #
|
|
68
|
+
|
|
69
|
+
def _auth_params(self) -> Dict[str, str]:
|
|
70
|
+
return {"email": self.email, "api_key": self.api_key}
|
|
71
|
+
|
|
72
|
+
def _request(self, base: str, method: str, params: Dict[str, Any]) -> Any:
|
|
73
|
+
url = f"{base}/{method}"
|
|
74
|
+
data = {**self._auth_params(), **params}
|
|
75
|
+
resp = self._http.post(url, data=data)
|
|
76
|
+
if resp.status_code >= 500:
|
|
77
|
+
raise OpensolrError(f"{method}: HTTP {resp.status_code}: {resp.text[:200]}")
|
|
78
|
+
try:
|
|
79
|
+
body = resp.json()
|
|
80
|
+
except json.JSONDecodeError as exc:
|
|
81
|
+
raise OpensolrError(f"{method}: non-JSON response: {resp.text[:200]}") from exc
|
|
82
|
+
if isinstance(body, dict) and body.get("status") is False:
|
|
83
|
+
raise OpensolrError(f"{method}: {body.get('msg', body)}")
|
|
84
|
+
return body
|
|
85
|
+
|
|
86
|
+
def mgmt(self, method: str, **params: Any) -> Any:
|
|
87
|
+
return self._request(MGMT_BASE, method, params)
|
|
88
|
+
|
|
89
|
+
def ai(self, method: str, **params: Any) -> Any:
|
|
90
|
+
return self._request(AI_BASE, method, params)
|
|
91
|
+
|
|
92
|
+
# ------------------------------------------------------------------ #
|
|
93
|
+
# management #
|
|
94
|
+
# ------------------------------------------------------------------ #
|
|
95
|
+
|
|
96
|
+
def get_index_list(self) -> List[Dict[str, str]]:
|
|
97
|
+
return self.mgmt("get_index_list")
|
|
98
|
+
|
|
99
|
+
def get_core_info(self, index: str, refresh: bool = False) -> Dict[str, Any]:
|
|
100
|
+
"""Resolve an index's Solr endpoint + HTTP auth. Cached per client."""
|
|
101
|
+
if not refresh and index in self._core_info_cache:
|
|
102
|
+
return self._core_info_cache[index]
|
|
103
|
+
body = self.mgmt("get_core_info", core_name=index)
|
|
104
|
+
msg = body.get("msg") if isinstance(body, dict) else None
|
|
105
|
+
if not isinstance(msg, dict) or "info" not in msg:
|
|
106
|
+
raise OpensolrError(f"get_core_info({index}): unexpected response: {str(body)[:200]}")
|
|
107
|
+
info = msg["info"]
|
|
108
|
+
self._core_info_cache[index] = info
|
|
109
|
+
return info
|
|
110
|
+
|
|
111
|
+
def vector_regions(self) -> List[Dict[str, str]]:
|
|
112
|
+
"""Live list of vector-enabled environments (Solr 9.x + knn_vector +
|
|
113
|
+
hybrid parser): ``[{environment, country, solr_version}, ...]``.
|
|
114
|
+
|
|
115
|
+
Cached per client. Additional dedicated regions can be deployed on
|
|
116
|
+
request (paid) — contact support@opensolr.com.
|
|
117
|
+
"""
|
|
118
|
+
if not hasattr(self, "_vector_regions_cache"):
|
|
119
|
+
body = self.mgmt("vector_regions")
|
|
120
|
+
self._vector_regions_cache = body if isinstance(body, list) else []
|
|
121
|
+
return self._vector_regions_cache
|
|
122
|
+
|
|
123
|
+
def create_index(self, index: str, location: str = "us") -> Dict[str, Any]:
|
|
124
|
+
"""Create a vector-enabled index in a vector location.
|
|
125
|
+
|
|
126
|
+
``location`` is an alias ("us", "de", "fi") or a raw Opensolr
|
|
127
|
+
environment identifier. Validated against the live ``vector_regions``
|
|
128
|
+
list when reachable; otherwise the server has the final word.
|
|
129
|
+
"""
|
|
130
|
+
env = resolve_location(location)
|
|
131
|
+
try:
|
|
132
|
+
live = {r["environment"] for r in self.vector_regions()}
|
|
133
|
+
except OpensolrError:
|
|
134
|
+
live = set(VECTOR_LOCATIONS.values()) # offline fallback
|
|
135
|
+
if live and env not in live:
|
|
136
|
+
raise ValueError(
|
|
137
|
+
f"{location!r} is not a vector-enabled Opensolr location. "
|
|
138
|
+
f"Currently available: {sorted(live)}. Additional regions can "
|
|
139
|
+
f"be deployed on request — contact support@opensolr.com."
|
|
140
|
+
)
|
|
141
|
+
return self.mgmt(
|
|
142
|
+
"create_index", index_name=index, core_type="generic", server_country=env
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# ------------------------------------------------------------------ #
|
|
146
|
+
# AI #
|
|
147
|
+
# ------------------------------------------------------------------ #
|
|
148
|
+
|
|
149
|
+
def embed(self, index: str, text: str, is_query: bool = False) -> List[float]:
|
|
150
|
+
body = self.ai(
|
|
151
|
+
"embed", index_name=index, payload=text, is_query="1" if is_query else "0"
|
|
152
|
+
)
|
|
153
|
+
if not isinstance(body, list) or not body:
|
|
154
|
+
raise OpensolrError(f"embed: unexpected response: {str(body)[:200]}")
|
|
155
|
+
return body
|
|
156
|
+
|
|
157
|
+
def batch_embed(self, index: str, texts: List[str]) -> List[List[float]]:
|
|
158
|
+
"""Embed many texts. Chunks transparently at the server's batch limit."""
|
|
159
|
+
out: List[List[float]] = []
|
|
160
|
+
for i in range(0, len(texts), BATCH_EMBED_MAX):
|
|
161
|
+
chunk = texts[i : i + BATCH_EMBED_MAX]
|
|
162
|
+
resp = self._http.post(
|
|
163
|
+
f"{AI_BASE}/batch_embed",
|
|
164
|
+
json={
|
|
165
|
+
**self._auth_params(),
|
|
166
|
+
"index_name": index,
|
|
167
|
+
"payloads": chunk,
|
|
168
|
+
},
|
|
169
|
+
)
|
|
170
|
+
try:
|
|
171
|
+
body = resp.json()
|
|
172
|
+
except json.JSONDecodeError as exc:
|
|
173
|
+
raise OpensolrError(f"batch_embed: non-JSON response: {resp.text[:200]}") from exc
|
|
174
|
+
if isinstance(body, dict) and body.get("status") is False:
|
|
175
|
+
raise OpensolrError(f"batch_embed: {body.get('msg', body)}")
|
|
176
|
+
embeddings = body.get("embeddings") if isinstance(body, dict) else None
|
|
177
|
+
if not isinstance(embeddings, list) or len(embeddings) != len(chunk):
|
|
178
|
+
raise OpensolrError(f"batch_embed: unexpected response: {str(body)[:200]}")
|
|
179
|
+
out.extend(embeddings)
|
|
180
|
+
return out
|
|
181
|
+
|
|
182
|
+
def embed_and_search(self, index: str, query: str, rows: int = 10, **params: Any) -> Dict[str, Any]:
|
|
183
|
+
"""Server-side one-shot: embed the query, run hybrid search, return docs."""
|
|
184
|
+
body = self.ai(
|
|
185
|
+
"embed_and_search",
|
|
186
|
+
index_name=index,
|
|
187
|
+
q=query,
|
|
188
|
+
rows=rows,
|
|
189
|
+
**{"in": "all", "fresh": "no", **params},
|
|
190
|
+
)
|
|
191
|
+
return body
|
|
192
|
+
|
|
193
|
+
# ------------------------------------------------------------------ #
|
|
194
|
+
# direct Solr #
|
|
195
|
+
# ------------------------------------------------------------------ #
|
|
196
|
+
|
|
197
|
+
def solr_endpoint(self, index: str) -> Tuple[str, Optional[Tuple[str, str]]]:
|
|
198
|
+
"""Return (base_url, basic_auth) for the index's native Solr API."""
|
|
199
|
+
info = self.get_core_info(index)
|
|
200
|
+
url = info.get("connection_url")
|
|
201
|
+
if not url:
|
|
202
|
+
raise OpensolrError(f"No connection_url for index {index!r}")
|
|
203
|
+
auth = None
|
|
204
|
+
if info.get("auth_username"):
|
|
205
|
+
auth = (info["auth_username"], info.get("auth_password") or "")
|
|
206
|
+
return url, auth
|
|
207
|
+
|
|
208
|
+
def solr_select(self, index: str, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
209
|
+
base, auth = self.solr_endpoint(index)
|
|
210
|
+
resp = self._http.post(f"{base}/select", data={"wt": "json", **params}, auth=auth)
|
|
211
|
+
resp.raise_for_status()
|
|
212
|
+
return resp.json()
|
|
213
|
+
|
|
214
|
+
def ai_summary(self, index: str, query: str, **params: Any) -> str:
|
|
215
|
+
"""RAG answer generated from the index's own content. Returns plain text."""
|
|
216
|
+
resp = self._http.post(
|
|
217
|
+
f"{AI_BASE}/ai_summary",
|
|
218
|
+
data={
|
|
219
|
+
**self._auth_params(),
|
|
220
|
+
"index_name": index,
|
|
221
|
+
"query": query,
|
|
222
|
+
"stream": "false",
|
|
223
|
+
**params,
|
|
224
|
+
},
|
|
225
|
+
)
|
|
226
|
+
if resp.status_code >= 400:
|
|
227
|
+
raise OpensolrError(f"ai_summary: HTTP {resp.status_code}: {resp.text[:200]}")
|
|
228
|
+
return resp.text
|
|
229
|
+
|
|
230
|
+
def solr_update(self, index: str, payload: Any, commit: bool = True) -> Dict[str, Any]:
|
|
231
|
+
base, auth = self.solr_endpoint(index)
|
|
232
|
+
params = {"commit": "true"} if commit else {"commitWithin": "10000"}
|
|
233
|
+
resp = self._http.post(
|
|
234
|
+
f"{base}/update",
|
|
235
|
+
params=params,
|
|
236
|
+
json=payload,
|
|
237
|
+
auth=auth,
|
|
238
|
+
)
|
|
239
|
+
resp.raise_for_status()
|
|
240
|
+
return resp.json()
|
|
241
|
+
|
|
242
|
+
def close(self) -> None:
|
|
243
|
+
self._http.close()
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Opensolr MCP server — managed Apache Solr with hybrid (BM25 + kNN) search
|
|
2
|
+
and server-side GPU embeddings, exposed as Model Context Protocol tools.
|
|
3
|
+
|
|
4
|
+
Credentials come from the environment:
|
|
5
|
+
OPENSOLR_EMAIL — Opensolr account email
|
|
6
|
+
OPENSOLR_API_KEY — Opensolr API key (Account > API in the control panel)
|
|
7
|
+
|
|
8
|
+
Run: ``opensolr-mcp`` (stdio transport — for Claude Desktop, Cursor, etc.)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import uuid
|
|
17
|
+
from typing import Any, Dict, List, Optional
|
|
18
|
+
|
|
19
|
+
from mcp.server import MCPServer
|
|
20
|
+
|
|
21
|
+
from .client import OpensolrClient, OpensolrError, resolve_location
|
|
22
|
+
|
|
23
|
+
mcp = MCPServer(
|
|
24
|
+
"opensolr",
|
|
25
|
+
instructions=(
|
|
26
|
+
"Managed Apache Solr search for the user's Opensolr account. "
|
|
27
|
+
"Use opensolr_search for retrieval (hybrid keyword+semantic by default), "
|
|
28
|
+
"opensolr_ai_answer for a grounded RAG answer, and the document tools "
|
|
29
|
+
"to index or remove content. Embedding happens server-side — tools "
|
|
30
|
+
"accept plain text, never vectors."
|
|
31
|
+
),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
_client: Optional[OpensolrClient] = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _get_client() -> OpensolrClient:
|
|
38
|
+
global _client
|
|
39
|
+
if _client is None:
|
|
40
|
+
email = os.environ.get("OPENSOLR_EMAIL", "")
|
|
41
|
+
api_key = os.environ.get("OPENSOLR_API_KEY", "")
|
|
42
|
+
if not (email and api_key):
|
|
43
|
+
raise OpensolrError(
|
|
44
|
+
"Set OPENSOLR_EMAIL and OPENSOLR_API_KEY in the MCP server "
|
|
45
|
+
"environment (free account: https://opensolr.com/register)."
|
|
46
|
+
)
|
|
47
|
+
_client = OpensolrClient(email, api_key)
|
|
48
|
+
return _client
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
_META_KEY_RE = re.compile(r"[^a-z0-9_]+")
|
|
52
|
+
|
|
53
|
+
_HYBRID_MODES = ("union", "keywords_required", "meaning_required", "intersection")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _doc_out(solr_doc: Dict[str, Any]) -> Dict[str, Any]:
|
|
57
|
+
"""Shape a Solr doc for LLM consumption: id, title, text, score, metadata."""
|
|
58
|
+
|
|
59
|
+
def _flat(v: Any) -> Any:
|
|
60
|
+
if isinstance(v, list):
|
|
61
|
+
return v[0] if len(v) == 1 else v
|
|
62
|
+
return v
|
|
63
|
+
|
|
64
|
+
metadata: Dict[str, Any] = {}
|
|
65
|
+
raw = _flat(solr_doc.get("meta_lc_json"))
|
|
66
|
+
if raw:
|
|
67
|
+
try:
|
|
68
|
+
metadata = json.loads(raw)
|
|
69
|
+
except (TypeError, json.JSONDecodeError):
|
|
70
|
+
metadata = {}
|
|
71
|
+
text = _flat(solr_doc.get("text", "")) or ""
|
|
72
|
+
if isinstance(text, list):
|
|
73
|
+
text = " ".join(str(t) for t in text)
|
|
74
|
+
return {
|
|
75
|
+
"id": str(_flat(solr_doc.get("id", ""))),
|
|
76
|
+
"title": str(_flat(solr_doc.get("title", "")) or "")[:200],
|
|
77
|
+
"text": str(text)[:2000],
|
|
78
|
+
"score": float(_flat(solr_doc.get("score", 0.0)) or 0.0),
|
|
79
|
+
"metadata": metadata,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@mcp.tool()
|
|
84
|
+
def opensolr_search(
|
|
85
|
+
index: str,
|
|
86
|
+
query: str,
|
|
87
|
+
k: int = 5,
|
|
88
|
+
hybrid: bool = True,
|
|
89
|
+
mode: str = "union",
|
|
90
|
+
alpha: float = 0.5,
|
|
91
|
+
filter_query: Optional[str] = None,
|
|
92
|
+
) -> List[Dict[str, Any]]:
|
|
93
|
+
"""Search an Opensolr index and return the k most relevant documents.
|
|
94
|
+
|
|
95
|
+
By default runs HYBRID search: BM25 keyword matching and semantic kNN
|
|
96
|
+
(server-side embeddings) fused per document. Set hybrid=false for pure
|
|
97
|
+
semantic search. mode is one of union, keywords_required,
|
|
98
|
+
meaning_required, intersection; alpha balances semantic (0) vs lexical (1).
|
|
99
|
+
filter_query accepts a raw Solr fq expression, e.g. 'meta_category:"docs"'.
|
|
100
|
+
"""
|
|
101
|
+
client = _get_client()
|
|
102
|
+
vector = client.embed(index, query, is_query=True)
|
|
103
|
+
compact = json.dumps(vector, separators=(",", ":"))
|
|
104
|
+
knn = f"{{!knn f=embeddings topK={max(k, 10)}}}{compact}"
|
|
105
|
+
|
|
106
|
+
params: Dict[str, Any] = {"rows": k, "fl": "*,score"}
|
|
107
|
+
if hybrid:
|
|
108
|
+
if mode not in _HYBRID_MODES:
|
|
109
|
+
raise ValueError(f"mode must be one of {_HYBRID_MODES}")
|
|
110
|
+
clean = query.replace("{", " ").replace("}", " ").replace('"', " ")
|
|
111
|
+
params["q"] = (
|
|
112
|
+
f"{{!hybrid lexical=$lexicalRaw vector=$vectorQuery "
|
|
113
|
+
f"mode={mode} alpha={alpha} topN={max(k, 10)}}}"
|
|
114
|
+
)
|
|
115
|
+
params["lexicalRaw"] = f'{{!edismax qf="title^100 text^1"}}{clean}'
|
|
116
|
+
params["vectorQuery"] = knn
|
|
117
|
+
else:
|
|
118
|
+
params["q"] = knn
|
|
119
|
+
if filter_query:
|
|
120
|
+
params["fq"] = filter_query
|
|
121
|
+
|
|
122
|
+
body = client.solr_select(index, params)
|
|
123
|
+
return [_doc_out(d) for d in body["response"]["docs"]]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@mcp.tool()
|
|
127
|
+
def opensolr_ai_answer(index: str, query: str) -> str:
|
|
128
|
+
"""Ask a question and get a grounded RAG answer generated ONLY from the
|
|
129
|
+
content already indexed in the given Opensolr index (retrieval + LLM,
|
|
130
|
+
all server-side)."""
|
|
131
|
+
return _get_client().ai_summary(index, query)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@mcp.tool()
|
|
135
|
+
def opensolr_list_indexes() -> List[Dict[str, str]]:
|
|
136
|
+
"""List all search indexes in the connected Opensolr account."""
|
|
137
|
+
return _get_client().get_index_list()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@mcp.tool()
|
|
141
|
+
def opensolr_index_info(index: str) -> Dict[str, Any]:
|
|
142
|
+
"""Get connection details for an index: Solr URL, version, environment.
|
|
143
|
+
(Credentials are intentionally not returned.)"""
|
|
144
|
+
info = _get_client().get_core_info(index)
|
|
145
|
+
return {
|
|
146
|
+
"connection_url": info.get("connection_url"),
|
|
147
|
+
"solr_version": info.get("solr_version"),
|
|
148
|
+
"environment": info.get("environment_identifier"),
|
|
149
|
+
"type": info.get("type"),
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@mcp.tool()
|
|
154
|
+
def opensolr_add_documents(
|
|
155
|
+
index: str,
|
|
156
|
+
texts: List[str],
|
|
157
|
+
metadatas: Optional[List[Dict[str, Any]]] = None,
|
|
158
|
+
ids: Optional[List[str]] = None,
|
|
159
|
+
) -> List[str]:
|
|
160
|
+
"""Index plain-text documents with optional metadata dicts. Embeddings are
|
|
161
|
+
computed server-side automatically. Returns the document ids."""
|
|
162
|
+
client = _get_client()
|
|
163
|
+
metadatas = metadatas or [{} for _ in texts]
|
|
164
|
+
ids = ids or [str(uuid.uuid4()) for _ in texts]
|
|
165
|
+
if not (len(texts) == len(metadatas) == len(ids)):
|
|
166
|
+
raise ValueError("texts, metadatas and ids must have the same length")
|
|
167
|
+
|
|
168
|
+
vectors = client.batch_embed(index, texts)
|
|
169
|
+
docs = []
|
|
170
|
+
for text, meta, doc_id, vector in zip(texts, metadatas, ids, vectors):
|
|
171
|
+
doc: Dict[str, Any] = {
|
|
172
|
+
"id": doc_id,
|
|
173
|
+
"text": text,
|
|
174
|
+
"embeddings": vector,
|
|
175
|
+
"meta_lc_json": json.dumps(meta, ensure_ascii=False),
|
|
176
|
+
"title": str(meta.get("title") or text[:100]),
|
|
177
|
+
}
|
|
178
|
+
for key, value in (meta or {}).items():
|
|
179
|
+
if isinstance(value, (str, int, float, bool)):
|
|
180
|
+
doc[f"meta_{_META_KEY_RE.sub('_', str(key).lower()).strip('_')}"] = str(value)
|
|
181
|
+
docs.append(doc)
|
|
182
|
+
client.solr_update(index, docs)
|
|
183
|
+
return ids
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@mcp.tool()
|
|
187
|
+
def opensolr_delete_documents(index: str, ids: List[str]) -> str:
|
|
188
|
+
"""Delete documents from an index by their ids."""
|
|
189
|
+
_get_client().solr_update(index, {"delete": list(ids)})
|
|
190
|
+
return f"deleted {len(ids)} document(s)"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@mcp.tool()
|
|
194
|
+
def opensolr_create_index(index: str, location: str = "us") -> Dict[str, Any]:
|
|
195
|
+
"""Create a new vector-enabled Opensolr index. location: us, de, fi, or
|
|
196
|
+
any environment id from opensolr_vector_regions. Additional dedicated
|
|
197
|
+
regions can be deployed on request (support@opensolr.com)."""
|
|
198
|
+
return _get_client().create_index(index, resolve_location(location))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@mcp.tool()
|
|
202
|
+
def opensolr_vector_regions() -> List[Dict[str, str]]:
|
|
203
|
+
"""List the vector-enabled Opensolr environments currently available
|
|
204
|
+
(Solr 9.x with dense vectors and the hybrid query parser)."""
|
|
205
|
+
return _get_client().vector_regions()
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def main() -> None:
|
|
209
|
+
mcp.run()
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
if __name__ == "__main__":
|
|
213
|
+
main()
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: opensolr-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for Opensolr — managed Apache Solr with hybrid BM25+kNN search, server-side embeddings, and RAG answers as agent tools
|
|
5
|
+
Author-email: Opensolr <support@opensolr.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://opensolr.com/langchain
|
|
8
|
+
Project-URL: Repository, https://github.com/phpcip/opensolr-mcp
|
|
9
|
+
Keywords: mcp,model-context-protocol,opensolr,solr,hybrid-search,rag,agents,vector
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Requires-Dist: mcp>=1.2.0
|
|
16
|
+
Requires-Dist: httpx>=0.25.0
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# opensolr-mcp
|
|
20
|
+
|
|
21
|
+
MCP (Model Context Protocol) server for [Opensolr](https://opensolr.com) —
|
|
22
|
+
gives any AI agent **managed Apache Solr search** as tools: hybrid
|
|
23
|
+
(BM25 + kNN) retrieval, server-side GPU embeddings, document indexing, and
|
|
24
|
+
grounded RAG answers.
|
|
25
|
+
|
|
26
|
+
No embedding model to configure. No vector database to run. One API key.
|
|
27
|
+
|
|
28
|
+
## Tools
|
|
29
|
+
|
|
30
|
+
| Tool | What it does |
|
|
31
|
+
|---|---|
|
|
32
|
+
| `opensolr_search` | Hybrid (keyword + semantic) or pure semantic search, with Solr filters |
|
|
33
|
+
| `opensolr_ai_answer` | Grounded RAG answer generated only from your indexed content |
|
|
34
|
+
| `opensolr_add_documents` | Index plain text + metadata (embedded server-side) |
|
|
35
|
+
| `opensolr_delete_documents` | Remove documents by id |
|
|
36
|
+
| `opensolr_list_indexes` / `opensolr_index_info` | Inspect the account's indexes |
|
|
37
|
+
| `opensolr_create_index` | Provision a vector-enabled index (`us`, `de`, `fi`) |
|
|
38
|
+
| `opensolr_vector_regions` | Live list of vector-enabled regions |
|
|
39
|
+
|
|
40
|
+
## Setup
|
|
41
|
+
|
|
42
|
+
Get a free Opensolr account (15-day trial, no card) at
|
|
43
|
+
[opensolr.com/register](https://opensolr.com/register) and copy your API key
|
|
44
|
+
from **Account**.
|
|
45
|
+
|
|
46
|
+
### Claude Desktop / Claude Code
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{
|
|
50
|
+
"mcpServers": {
|
|
51
|
+
"opensolr": {
|
|
52
|
+
"command": "uvx",
|
|
53
|
+
"args": ["opensolr-mcp"],
|
|
54
|
+
"env": {
|
|
55
|
+
"OPENSOLR_EMAIL": "you@example.com",
|
|
56
|
+
"OPENSOLR_API_KEY": "YOUR_OPENSOLR_API_KEY"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Cursor / Windsurf / any MCP client
|
|
64
|
+
|
|
65
|
+
Same shape — stdio transport, command `uvx opensolr-mcp` (or
|
|
66
|
+
`pipx run opensolr-mcp`), with `OPENSOLR_EMAIL` and `OPENSOLR_API_KEY` in env.
|
|
67
|
+
|
|
68
|
+
## Example agent session
|
|
69
|
+
|
|
70
|
+
> **You:** Index our FAQ answers, then find everything about refunds.
|
|
71
|
+
>
|
|
72
|
+
> The agent calls `opensolr_add_documents(index="faq__dense", texts=[...])`,
|
|
73
|
+
> then `opensolr_search(index="faq__dense", query="refund policy", hybrid=True)`
|
|
74
|
+
> — BM25 catches the exact word "refund", kNN catches "giving customers their
|
|
75
|
+
> money back", and the scores fuse per document.
|
|
76
|
+
|
|
77
|
+
## Notes
|
|
78
|
+
|
|
79
|
+
- Vector-enabled indexes run on Opensolr's Solr 9.x environments — currently
|
|
80
|
+
`us` (Chicago), `de` (Germany), `fi` (Finland), fetched live via
|
|
81
|
+
`opensolr_vector_regions`. Additional dedicated regions can be deployed on
|
|
82
|
+
request (paid add-on): [support@opensolr.com](mailto:support@opensolr.com).
|
|
83
|
+
- Every index is also plain Apache Solr with the native `/select` API —
|
|
84
|
+
nothing is locked behind the tools.
|
|
85
|
+
- Python sibling for LangChain: [`langchain-opensolr`](https://pypi.org/project/langchain-opensolr/) ·
|
|
86
|
+
Product page: [opensolr.com/langchain](https://opensolr.com/langchain)
|
|
87
|
+
|
|
88
|
+
MIT license.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
opensolr_mcp/__init__.py
|
|
5
|
+
opensolr_mcp/client.py
|
|
6
|
+
opensolr_mcp/server.py
|
|
7
|
+
opensolr_mcp.egg-info/PKG-INFO
|
|
8
|
+
opensolr_mcp.egg-info/SOURCES.txt
|
|
9
|
+
opensolr_mcp.egg-info/dependency_links.txt
|
|
10
|
+
opensolr_mcp.egg-info/entry_points.txt
|
|
11
|
+
opensolr_mcp.egg-info/requires.txt
|
|
12
|
+
opensolr_mcp.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
opensolr_mcp
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "opensolr-mcp"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "MCP server for Opensolr — managed Apache Solr with hybrid BM25+kNN search, server-side embeddings, and RAG answers as agent tools"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "Opensolr", email = "support@opensolr.com" }]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"mcp>=1.2.0",
|
|
15
|
+
"httpx>=0.25.0",
|
|
16
|
+
]
|
|
17
|
+
keywords = ["mcp", "model-context-protocol", "opensolr", "solr", "hybrid-search", "rag", "agents", "vector"]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://opensolr.com/langchain"
|
|
25
|
+
Repository = "https://github.com/phpcip/opensolr-mcp"
|
|
26
|
+
|
|
27
|
+
[project.scripts]
|
|
28
|
+
opensolr-mcp = "opensolr_mcp.server:main"
|
|
29
|
+
|
|
30
|
+
[tool.setuptools]
|
|
31
|
+
packages = ["opensolr_mcp"]
|