lucerna-mcp 0.3.4__py3-none-any.whl

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.
Files changed (39) hide show
  1. lucerna_mcp/__init__.py +94 -0
  2. lucerna_mcp/__main__.py +4 -0
  3. lucerna_mcp/api/__init__.py +1 -0
  4. lucerna_mcp/api/api_client_base.py +101 -0
  5. lucerna_mcp/api/api_client_opensearch.py +340 -0
  6. lucerna_mcp/api_client.py +11 -0
  7. lucerna_mcp/auth.py +128 -0
  8. lucerna_mcp/connector_sources.py +42 -0
  9. lucerna_mcp/connectors/__init__.py +1 -0
  10. lucerna_mcp/kg_ingest.py +148 -0
  11. lucerna_mcp/mcp/__init__.py +1 -0
  12. lucerna_mcp/mcp/mcp_opensearch.py +364 -0
  13. lucerna_mcp/mcp_config.json +1 -0
  14. lucerna_mcp/mcp_server.py +7 -0
  15. lucerna_mcp/models.py +31 -0
  16. lucerna_mcp/ontology/__init__.py +7 -0
  17. lucerna_mcp/ontology/certification.json +33 -0
  18. lucerna_mcp/ontology/fixtures/records.json +5 -0
  19. lucerna_mcp/ontology/mappings/source.yaml +9 -0
  20. lucerna_mcp/ontology/migrations/manifest.json +6 -0
  21. lucerna_mcp/ontology/opensearch.ttl +63 -0
  22. lucerna_mcp/ontology/shapes/connector.shacl.ttl +64 -0
  23. lucerna_mcp/skills/__init__.py +1 -0
  24. lucerna_mcp/skills/opensearch-index-operations/SKILL.md +80 -0
  25. lucerna_mcp/skills/opensearch-kg-ingestion/SKILL.md +67 -0
  26. lucerna_mcp/skills/opensearch-search/SKILL.md +79 -0
  27. lucerna_mcp-0.3.4.dist-info/METADATA +346 -0
  28. lucerna_mcp-0.3.4.dist-info/RECORD +39 -0
  29. lucerna_mcp-0.3.4.dist-info/WHEEL +5 -0
  30. lucerna_mcp-0.3.4.dist-info/entry_points.txt +8 -0
  31. lucerna_mcp-0.3.4.dist-info/licenses/LICENSE +21 -0
  32. lucerna_mcp-0.3.4.dist-info/top_level.txt +6 -0
  33. tests/conftest.py +0 -0
  34. tests/test_api_client.py +75 -0
  35. tests/test_auth_env.py +77 -0
  36. tests/test_dls_bundle.py +86 -0
  37. tests/test_kg_ingest.py +90 -0
  38. tests/test_search.py +95 -0
  39. tests/test_startup.py +10 -0
@@ -0,0 +1,94 @@
1
+ """lucerna-mcp: OpenSearch (Iceberg REST catalog) API + MCP Server + A2A Server."""
2
+
3
+ import importlib
4
+ import inspect
5
+ from typing import Any
6
+
7
+ __all__: list[str] = []
8
+
9
+ CORE_MODULES = ["lucerna_mcp.api_client"]
10
+ OPTIONAL_MODULES = {
11
+ "lucerna_mcp.mcp_server": "mcp",
12
+ }
13
+
14
+
15
+ def _expose_members(module):
16
+ for name, obj in inspect.getmembers(module):
17
+ if (inspect.isclass(obj) or inspect.isfunction(obj)) and not name.startswith(
18
+ "_"
19
+ ):
20
+ globals()[name] = obj
21
+ if name not in __all__:
22
+ __all__.append(name)
23
+
24
+
25
+ for module_name in CORE_MODULES:
26
+ module = importlib.import_module(module_name)
27
+ _expose_members(module)
28
+
29
+ _loaded_optional_modules: dict[str, Any] = {}
30
+
31
+
32
+ def _import_module_safely(module_name: str):
33
+ try:
34
+ return importlib.import_module(module_name)
35
+ except ImportError:
36
+ return None
37
+
38
+
39
+ _AVAILABILITY_FLAG_MARKERS = {
40
+ "_MCP_AVAILABLE": "mcp_server",
41
+ }
42
+
43
+
44
+ def _resolve_availability_flag(name: str) -> bool | None:
45
+ """Return the True/False availability flag for `name`, or None if `name`
46
+ isn't one of the recognized ``_*_AVAILABLE`` markers."""
47
+ marker = _AVAILABILITY_FLAG_MARKERS.get(name)
48
+ if marker is None:
49
+ return None
50
+ module_key = next((k for k in OPTIONAL_MODULES if marker in k), None)
51
+ return _import_module_safely(module_key) is not None if module_key else False
52
+
53
+
54
+ def _ensure_optional_module_loaded(module_name: str):
55
+ """Import and expose `module_name`'s public members once, then return it
56
+ (or the cached module, or None if it isn't installed)."""
57
+ if module_name not in _loaded_optional_modules:
58
+ module = _import_module_safely(module_name)
59
+ if module is not None:
60
+ _loaded_optional_modules[module_name] = module
61
+ _expose_members(module)
62
+ return _loaded_optional_modules.get(module_name)
63
+
64
+
65
+ def _lookup_optional_attribute(name: str) -> Any:
66
+ for module_name in OPTIONAL_MODULES:
67
+ module = _ensure_optional_module_loaded(module_name)
68
+ if module is not None and hasattr(module, name):
69
+ return getattr(module, name)
70
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
71
+
72
+
73
+ def __getattr__(name: str) -> Any:
74
+ flag = _resolve_availability_flag(name)
75
+ if flag is not None:
76
+ return flag
77
+ return _lookup_optional_attribute(name)
78
+
79
+
80
+ def __dir__() -> list[str]:
81
+ return sorted(list(globals().keys()) + __all__)
82
+
83
+
84
+ from agent_connector_sdk.connector import Connector
85
+
86
+ from lucerna_mcp.api_client import Api
87
+ from lucerna_mcp.auth import get_client
88
+
89
+ connector = Connector(
90
+ api=Api,
91
+ get_client=get_client,
92
+ tools="lucerna_mcp.mcp_server",
93
+ instructions="Lucerna MCP Server (CA-50 search tier) — index/alias/settings/rollover admin, BM25/kNN/hybrid search always run as the calling principal so document-level security applies, ingest-pipeline admin, DLS-bundle read/apply (never hand-authors a rule — only applies what CA-16/26 render), and a trigger-only reindex-from-KG tool.",
94
+ )
@@ -0,0 +1,4 @@
1
+ from agent_connector_sdk import run
2
+
3
+ if __name__ == "__main__":
4
+ run()
@@ -0,0 +1 @@
1
+ """API client subpackage for lucerna-mcp."""
@@ -0,0 +1,101 @@
1
+ """Shared transport primitives for the OpenSearch API wrapper.
2
+
3
+ CA-43's single hardest invariant (stated three times in the lane's source
4
+ specs, restated here so it cannot be missed by anyone editing this file):
5
+ **every OpenSearch request this package makes carries the calling
6
+ principal's own bearer token, attached per HTTP call — never a token baked
7
+ in once at client-construction time, and never a service-level/admin bypass
8
+ credential.** :class:`_PerCallBearerAuth` below is the structural mechanism
9
+ that makes this true: it is a ``requests.auth.AuthBase`` callable that
10
+ ``opensearchpy``'s ``RequestsHttpConnection`` stores as ``session.auth`` and
11
+ invokes fresh on every single outbound request (see ``requests``'
12
+ ``Session.send`` -> ``PreparedRequest.prepare_auth``) — so a token that
13
+ expires mid-session, or a caller whose identity changes between two tool
14
+ calls in the same process, is never silently reused.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from collections.abc import Callable
20
+ from typing import Any
21
+
22
+ from requests.auth import AuthBase
23
+
24
+
25
+ class OpenSearchApiError(RuntimeError):
26
+ """An OpenSearch call failed with a typed, non-2xx response (or no usable
27
+ calling-principal credential was available at all).
28
+
29
+ Never silently degraded to an empty result — a caller (tool layer, KG
30
+ ingest, the DLS-bundle applier) must be able to distinguish "no hits"
31
+ from "the call failed" / "unreachable" / "no principal token available".
32
+ """
33
+
34
+ def __init__(
35
+ self, message: str, *, status_code: int | str | None = None, body: Any = None
36
+ ):
37
+ super().__init__(message)
38
+ self.status_code = status_code
39
+ self.body = body
40
+
41
+
42
+ class _PerCallBearerAuth(AuthBase):
43
+ """A ``requests`` auth callable that mints the ``Authorization`` header
44
+ fresh on every request by invoking ``token_provider()``.
45
+
46
+ This is the structural half of the "always the calling principal, never
47
+ a bypass credential" invariant: ``opensearchpy.RequestsHttpConnection``
48
+ assigns whatever is passed as ``http_auth`` directly to
49
+ ``requests.Session.auth`` (verified against opensearch-py 3.2.0's own
50
+ source), and ``requests`` calls an ``AuthBase.__call__`` on EVERY
51
+ prepared request, not once at session construction. A client built from
52
+ this auth object cannot go stale, and cannot be handed a credential at
53
+ construction time that outlives the request it was minted for.
54
+ """
55
+
56
+ def __init__(self, token_provider: Callable[[], str]) -> None:
57
+ self._token_provider = token_provider
58
+
59
+ def __call__(self, r: Any) -> Any:
60
+ token = self._token_provider()
61
+ if not token:
62
+ raise OpenSearchApiError(
63
+ "no calling-principal bearer token was available at request time — "
64
+ "refusing to send an unauthenticated OpenSearch request rather than "
65
+ "silently falling back to an admin/service credential"
66
+ )
67
+ r.headers["Authorization"] = f"Bearer {token}"
68
+ return r
69
+
70
+
71
+ def raise_for_transport_error(exc: Exception, *, operation: str) -> None:
72
+ """Translate an ``opensearchpy`` transport exception into
73
+ :class:`OpenSearchApiError`, fail-closed and never swallowed.
74
+
75
+ A hibernating/misrouted backend or a transport-layer failure must never
76
+ look like "zero hits" — the exact class of bug recorded platform-wide
77
+ from the ServiceNow PDI case (HTTP 200 + HTML on every path treated as
78
+ silent success).
79
+ """
80
+ from opensearchpy.exceptions import NotFoundError, TransportError
81
+
82
+ if isinstance(exc, OpenSearchApiError):
83
+ # Already typed (e.g. _PerCallBearerAuth's "no calling-principal
84
+ # token available") — re-raise as-is rather than double-wrapping it
85
+ # inside a generic transport-failure message.
86
+ raise exc
87
+ if isinstance(exc, NotFoundError):
88
+ raise OpenSearchApiError(
89
+ f"OpenSearch {operation}: not found",
90
+ status_code=404,
91
+ body=getattr(exc, "info", None),
92
+ ) from exc
93
+ if isinstance(exc, TransportError):
94
+ raise OpenSearchApiError(
95
+ f"OpenSearch {operation} failed: {exc}",
96
+ status_code=getattr(exc, "status_code", None),
97
+ body=getattr(exc, "info", None),
98
+ ) from exc
99
+ raise OpenSearchApiError(
100
+ f"OpenSearch {operation} failed (transport): {exc}"
101
+ ) from exc
@@ -0,0 +1,340 @@
1
+ """OpenSearch client wrapping ``opensearchpy.OpenSearch`` for the search tier (CA-50).
2
+
3
+ Every method routes through one ``opensearchpy.OpenSearch`` instance whose
4
+ transport auth is :class:`~lucerna_mcp.api.api_client_base._PerCallBearerAuth`
5
+ — the calling principal's own token, re-minted on every request (see that
6
+ module's docstring for why this is structural rather than conventional).
7
+
8
+ **k-NN plugin**: disabled cluster-wide (pre-AVX2 homelab nodes — no AVX2-free
9
+ OpenSearch k-NN build exists upstream, `services/opensearch/AGENTS.md`).
10
+ `knn_search`/`hybrid_search` exist per this lane's scope but degrade to a
11
+ typed :class:`OpenSearchApiError` (naming the plugin as the cause) rather
12
+ than a bare 500 when the plugin refuses the query — verified live against
13
+ the deployed cluster (2.19.6): indexing a `knn_vector` document and running a
14
+ `knn` query both fail with `illegal_state_exception: "KNN plugin is
15
+ disabled..."`.
16
+
17
+ **Security-plugin admin surface** (DLS role read/apply) has no first-class
18
+ ``opensearchpy`` client namespace — both go through
19
+ ``client.transport.perform_request`` against ``/_plugins/_security/api/...``,
20
+ the same low-level path OpenSearch's own `securityadmin.sh`/Dashboards use.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import logging
27
+ from typing import Any
28
+ from urllib.parse import urlparse
29
+
30
+ from opensearchpy import OpenSearch, RequestsHttpConnection
31
+ from opensearchpy.exceptions import TransportError
32
+
33
+ from lucerna_mcp.api.api_client_base import (
34
+ OpenSearchApiError,
35
+ _PerCallBearerAuth,
36
+ raise_for_transport_error,
37
+ )
38
+
39
+ logger = logging.getLogger("lucerna_mcp.api")
40
+
41
+ __all__ = ["OpenSearchApi", "OpenSearchApiError"]
42
+
43
+ _MAX_SEARCH_SIZE = 100
44
+ _KNN_DISABLED_MARKERS = ("knn plugin is disabled", "knn.plugin.enabled")
45
+
46
+
47
+ def _is_knn_disabled_error(exc: Exception) -> bool:
48
+ text = str(exc).lower()
49
+ return any(marker in text for marker in _KNN_DISABLED_MARKERS)
50
+
51
+
52
+ class OpenSearchApi:
53
+ """Authenticated OpenSearch client — always constructed with a per-call
54
+ token provider, never a static baked-in credential.
55
+
56
+ ``base_url`` is the bare OpenSearch origin (e.g. ``http://localhost:9200``).
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ base_url: str,
62
+ token_provider: Any = None,
63
+ verify_certs: bool | str = True,
64
+ timeout: float = 30.0,
65
+ _test_basic_auth: tuple[str, str] | None = None,
66
+ ) -> None:
67
+ """Construct the client. ``token_provider`` (a zero-arg callable
68
+ returning a bearer token) is the ONLY production credential path —
69
+ ``lucerna_mcp.auth.get_client()`` is the sole caller that should
70
+ ever populate it, sourced from the calling principal's own delegated
71
+ token, never a fixed/service credential.
72
+
73
+ ``_test_basic_auth`` exists ONLY for this package's own live-proof
74
+ scripts/tests exercising this exact client class against OpenSearch's
75
+ pre-bundle internal-user DLS demonstration accounts
76
+ (``services/opensearch/AGENTS.md``'s W07 fixture — CA-16/26's real
77
+ policy bundle has not landed yet). It is never referenced by
78
+ ``auth.py`` or by any ``mcp/*.py`` tool — passing it from either would
79
+ be exactly the admin/service-bypass this package's own contract
80
+ forbids.
81
+ """
82
+ if token_provider is None and _test_basic_auth is None:
83
+ raise OpenSearchApiError(
84
+ "OpenSearchApi requires a token_provider callable — a static token "
85
+ "or no credential at all would violate the 'always the calling "
86
+ "principal, per-call' invariant this package exists to hold"
87
+ )
88
+ parsed = urlparse(base_url)
89
+ host = parsed.hostname or base_url
90
+ port = parsed.port or (443 if parsed.scheme != "http" else 9200)
91
+ use_ssl = parsed.scheme != "http"
92
+ self.base_url = base_url.rstrip("/")
93
+ if token_provider is not None:
94
+ http_auth: Any = _PerCallBearerAuth(token_provider)
95
+ else:
96
+ logger.warning(
97
+ "OpenSearchApi constructed with _test_basic_auth — this is the "
98
+ "package's TEST-ONLY escape hatch and must never be reachable "
99
+ "from auth.get_client() or any mcp/*.py tool"
100
+ )
101
+ http_auth = _test_basic_auth
102
+ self._client = OpenSearch(
103
+ hosts=[{"host": host, "port": port}],
104
+ http_auth=http_auth,
105
+ use_ssl=use_ssl,
106
+ verify_certs=verify_certs,
107
+ connection_class=RequestsHttpConnection,
108
+ timeout=timeout,
109
+ )
110
+
111
+ # ── low-level escape hatch (security-plugin admin API) ───────────────
112
+ def _perform(self, method: str, url: str, body: Any = None) -> Any:
113
+ try:
114
+ return self._client.transport.perform_request(method, url, body=body)
115
+ except Exception as exc: # noqa: BLE001 - normalized below
116
+ raise_for_transport_error(exc, operation=f"{method} {url}")
117
+
118
+ # ── cluster / plugin introspection ────────────────────────────────────
119
+ def cluster_health(self) -> dict[str, Any]:
120
+ try:
121
+ return self._client.cluster.health()
122
+ except Exception as exc: # noqa: BLE001
123
+ raise_for_transport_error(exc, operation="cluster_health")
124
+
125
+ def knn_plugin_enabled(self) -> bool:
126
+ """Live check of the k-NN plugin's runtime-enabled flag (never assumed)."""
127
+ # `include_defaults` isn't expressible via the high-level client's
128
+ # settings helper params without extra plumbing; ask the flat path
129
+ # directly, matching services/opensearch/AGENTS.md's own verification.
130
+ flat = self._perform(
131
+ "GET",
132
+ "/_cluster/settings?include_defaults=true&filter_path=**.knn.plugin.enabled",
133
+ )
134
+ try:
135
+ return str(flat["defaults"]["knn"]["plugin"]["enabled"]).lower() == "true"
136
+ except (KeyError, TypeError):
137
+ return False
138
+
139
+ # ── index admin (typed OntologyActions per DEC-CA-07) ─────────────────
140
+ def create_index(
141
+ self, index: str, body: dict[str, Any] | None = None
142
+ ) -> dict[str, Any]:
143
+ try:
144
+ return self._client.indices.create(index=index, body=body or {})
145
+ except Exception as exc: # noqa: BLE001
146
+ if (
147
+ isinstance(exc, TransportError)
148
+ and getattr(exc, "status_code", None) == 400
149
+ ):
150
+ info = getattr(exc, "info", {}) or {}
151
+ err = (info.get("error") or {}) if isinstance(info, dict) else {}
152
+ if err.get("type") == "resource_already_exists_exception":
153
+ # Idempotent per this lane's contract: creating an index
154
+ # that already exists is not an error.
155
+ return {
156
+ "index": index,
157
+ "acknowledged": True,
158
+ "already_exists": True,
159
+ }
160
+ raise_for_transport_error(exc, operation=f"create_index({index})")
161
+
162
+ def update_mapping(self, index: str, properties: dict[str, Any]) -> dict[str, Any]:
163
+ try:
164
+ return self._client.indices.put_mapping(
165
+ index=index, body={"properties": properties}
166
+ )
167
+ except Exception as exc: # noqa: BLE001
168
+ raise_for_transport_error(exc, operation=f"update_mapping({index})")
169
+
170
+ def manage_alias(self, actions: list[dict[str, Any]]) -> dict[str, Any]:
171
+ """Apply one or more alias actions (``{"add": {...}}``/``{"remove": {...}}``).
172
+
173
+ The Alias Swap idiom (remove old + add new in one call) is OpenSearch's
174
+ own idempotent primitive — this method is a thin pass-through to it.
175
+ """
176
+ try:
177
+ return self._client.indices.update_aliases(body={"actions": actions})
178
+ except Exception as exc: # noqa: BLE001
179
+ raise_for_transport_error(exc, operation="manage_alias")
180
+
181
+ def get_aliases(self, index: str = "*") -> dict[str, Any]:
182
+ try:
183
+ return self._client.indices.get_alias(index=index)
184
+ except Exception as exc: # noqa: BLE001
185
+ raise_for_transport_error(exc, operation=f"get_aliases({index})")
186
+
187
+ def update_settings(self, index: str, settings: dict[str, Any]) -> dict[str, Any]:
188
+ try:
189
+ return self._client.indices.put_settings(
190
+ index=index, body={"index": settings}
191
+ )
192
+ except Exception as exc: # noqa: BLE001
193
+ raise_for_transport_error(exc, operation=f"update_settings({index})")
194
+
195
+ def rollover(
196
+ self,
197
+ alias: str,
198
+ conditions: dict[str, Any] | None = None,
199
+ new_index: str | None = None,
200
+ ) -> dict[str, Any]:
201
+ try:
202
+ target = f"{alias}/{new_index}" if new_index else alias
203
+ return self._client.indices.rollover(
204
+ alias=target, body={"conditions": conditions or {}}
205
+ )
206
+ except Exception as exc: # noqa: BLE001
207
+ raise_for_transport_error(exc, operation=f"rollover({alias})")
208
+
209
+ def delete_index(self, index: str) -> dict[str, Any]:
210
+ try:
211
+ return self._client.indices.delete(index=index)
212
+ except Exception as exc: # noqa: BLE001
213
+ raise_for_transport_error(exc, operation=f"delete_index({index})")
214
+
215
+ # ── search (always the calling principal — DLS applies per-request) ──
216
+ def search(
217
+ self,
218
+ index: str,
219
+ query: dict[str, Any],
220
+ size: int = 10,
221
+ _source: list[str] | None = None,
222
+ ) -> dict[str, Any]:
223
+ capped = min(max(int(size), 0), _MAX_SEARCH_SIZE)
224
+ body: dict[str, Any] = {"query": query, "size": capped}
225
+ if _source is not None:
226
+ body["_source"] = _source
227
+ try:
228
+ return self._client.search(index=index, body=body)
229
+ except Exception as exc: # noqa: BLE001
230
+ raise_for_transport_error(exc, operation=f"search({index})")
231
+
232
+ def knn_search(
233
+ self,
234
+ index: str,
235
+ field: str,
236
+ vector: list[float],
237
+ k: int = 10,
238
+ size: int = 10,
239
+ ) -> dict[str, Any]:
240
+ capped = min(max(int(size), 0), _MAX_SEARCH_SIZE)
241
+ body = {
242
+ "size": capped,
243
+ "query": {"knn": {field: {"vector": vector, "k": k}}},
244
+ }
245
+ try:
246
+ return self._client.search(index=index, body=body)
247
+ except Exception as exc: # noqa: BLE001
248
+ if _is_knn_disabled_error(exc):
249
+ raise OpenSearchApiError(
250
+ "k-NN plugin is disabled on this cluster (pre-AVX2 homelab "
251
+ "nodes — no AVX2-free k-NN build exists upstream, "
252
+ "services/opensearch/AGENTS.md). opensearch_knn_search cannot "
253
+ "run here; use opensearch_search (BM25) or eg-vector/vector-mcp "
254
+ "for vector search instead.",
255
+ status_code=getattr(exc, "status_code", None),
256
+ ) from exc
257
+ raise_for_transport_error(exc, operation=f"knn_search({index})")
258
+
259
+ def hybrid_search(
260
+ self,
261
+ index: str,
262
+ queries: list[dict[str, Any]],
263
+ size: int = 10,
264
+ ) -> dict[str, Any]:
265
+ """OpenSearch's native hybrid (lexical + vector) query compound clause.
266
+
267
+ Degrades the same way as :meth:`knn_search` when any sub-query is a
268
+ ``knn`` clause and the plugin is disabled — never a bare 500.
269
+ """
270
+ capped = min(max(int(size), 0), _MAX_SEARCH_SIZE)
271
+ body = {"size": capped, "query": {"hybrid": {"queries": queries}}}
272
+ try:
273
+ return self._client.search(
274
+ index=index,
275
+ body=body,
276
+ params={"search_pipeline": "hybrid-search-pipeline"},
277
+ )
278
+ except Exception as exc: # noqa: BLE001
279
+ if _is_knn_disabled_error(exc):
280
+ raise OpenSearchApiError(
281
+ "hybrid search includes a k-NN clause and the k-NN plugin is "
282
+ "disabled on this cluster (pre-AVX2 homelab nodes). Run a "
283
+ "lexical-only opensearch_search instead, or drop the vector "
284
+ "sub-query.",
285
+ status_code=getattr(exc, "status_code", None),
286
+ ) from exc
287
+ raise_for_transport_error(exc, operation=f"hybrid_search({index})")
288
+
289
+ # ── ingest pipelines ───────────────────────────────────────────────────
290
+ def put_ingest_pipeline(
291
+ self, pipeline_id: str, body: dict[str, Any]
292
+ ) -> dict[str, Any]:
293
+ try:
294
+ return self._client.ingest.put_pipeline(id=pipeline_id, body=body)
295
+ except Exception as exc: # noqa: BLE001
296
+ raise_for_transport_error(
297
+ exc, operation=f"put_ingest_pipeline({pipeline_id})"
298
+ )
299
+
300
+ def get_ingest_pipeline(self, pipeline_id: str) -> dict[str, Any]:
301
+ try:
302
+ return self._client.ingest.get_pipeline(id=pipeline_id)
303
+ except Exception as exc: # noqa: BLE001
304
+ raise_for_transport_error(
305
+ exc, operation=f"get_ingest_pipeline({pipeline_id})"
306
+ )
307
+
308
+ # ── security / DLS (never hand-authored — applies a CA-16/26 bundle) ──
309
+ def read_dls_rules(self, role: str) -> dict[str, Any]:
310
+ """Read one OpenSearch security-plugin role's index permissions/DLS query.
311
+
312
+ Read-only — this is the *only* security-group method the ``search``
313
+ invariant does not apply to as strictly (it reads config, it does not
314
+ search data), but it still runs as the calling principal's own token
315
+ (a principal with no ``security_rest_api_access`` simply gets a 403
316
+ from OpenSearch itself, which is surfaced, not swallowed).
317
+ """
318
+ return self._perform("GET", f"/_plugins/_security/api/roles/{role}")
319
+
320
+ def apply_dls_bundle_rendering(
321
+ self, role: str, index_pattern: str, dls_query: dict[str, Any]
322
+ ) -> dict[str, Any]:
323
+ """Apply ONE already-validated ``renderings.opensearch`` entry.
324
+
325
+ Callers (``mcp/mcp_opensearch.py``) are responsible for validating the
326
+ bundle's shape and provenance BEFORE calling this — this method never
327
+ constructs a DLS predicate itself, it only PUTs the one already given
328
+ to it, matching this package's "never hand-author a DLS rule" invariant.
329
+ """
330
+ body = {
331
+ "cluster_permissions": [],
332
+ "index_permissions": [
333
+ {
334
+ "index_patterns": [index_pattern],
335
+ "dls": json.dumps(dls_query),
336
+ "allowed_actions": ["read"],
337
+ }
338
+ ],
339
+ }
340
+ return self._perform("PUT", f"/_plugins/_security/api/roles/{role}", body=body)
@@ -0,0 +1,11 @@
1
+ """Public client facade for lucerna_mcp."""
2
+
3
+ from lucerna_mcp.api.api_client_opensearch import OpenSearchApi, OpenSearchApiError
4
+
5
+ __all__ = ["Api", "OpenSearchApiError"]
6
+
7
+
8
+ class Api(OpenSearchApi):
9
+ """Authenticated OpenSearch client — always the calling principal's token."""
10
+
11
+ pass