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.
@@ -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,5 @@
1
+ """Opensolr MCP server — hybrid Solr search + RAG as Model Context Protocol tools."""
2
+
3
+ from opensolr_mcp.client import OpensolrClient, OpensolrError
4
+
5
+ __all__ = ["OpensolrClient", "OpensolrError"]
@@ -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,2 @@
1
+ [console_scripts]
2
+ opensolr-mcp = opensolr_mcp.server:main
@@ -0,0 +1,2 @@
1
+ mcp>=1.2.0
2
+ httpx>=0.25.0
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+