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.
- lucerna_mcp/__init__.py +94 -0
- lucerna_mcp/__main__.py +4 -0
- lucerna_mcp/api/__init__.py +1 -0
- lucerna_mcp/api/api_client_base.py +101 -0
- lucerna_mcp/api/api_client_opensearch.py +340 -0
- lucerna_mcp/api_client.py +11 -0
- lucerna_mcp/auth.py +128 -0
- lucerna_mcp/connector_sources.py +42 -0
- lucerna_mcp/connectors/__init__.py +1 -0
- lucerna_mcp/kg_ingest.py +148 -0
- lucerna_mcp/mcp/__init__.py +1 -0
- lucerna_mcp/mcp/mcp_opensearch.py +364 -0
- lucerna_mcp/mcp_config.json +1 -0
- lucerna_mcp/mcp_server.py +7 -0
- lucerna_mcp/models.py +31 -0
- lucerna_mcp/ontology/__init__.py +7 -0
- lucerna_mcp/ontology/certification.json +33 -0
- lucerna_mcp/ontology/fixtures/records.json +5 -0
- lucerna_mcp/ontology/mappings/source.yaml +9 -0
- lucerna_mcp/ontology/migrations/manifest.json +6 -0
- lucerna_mcp/ontology/opensearch.ttl +63 -0
- lucerna_mcp/ontology/shapes/connector.shacl.ttl +64 -0
- lucerna_mcp/skills/__init__.py +1 -0
- lucerna_mcp/skills/opensearch-index-operations/SKILL.md +80 -0
- lucerna_mcp/skills/opensearch-kg-ingestion/SKILL.md +67 -0
- lucerna_mcp/skills/opensearch-search/SKILL.md +79 -0
- lucerna_mcp-0.3.4.dist-info/METADATA +346 -0
- lucerna_mcp-0.3.4.dist-info/RECORD +39 -0
- lucerna_mcp-0.3.4.dist-info/WHEEL +5 -0
- lucerna_mcp-0.3.4.dist-info/entry_points.txt +8 -0
- lucerna_mcp-0.3.4.dist-info/licenses/LICENSE +21 -0
- lucerna_mcp-0.3.4.dist-info/top_level.txt +6 -0
- tests/conftest.py +0 -0
- tests/test_api_client.py +75 -0
- tests/test_auth_env.py +77 -0
- tests/test_dls_bundle.py +86 -0
- tests/test_kg_ingest.py +90 -0
- tests/test_search.py +95 -0
- tests/test_startup.py +10 -0
lucerna_mcp/__init__.py
ADDED
|
@@ -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
|
+
)
|
lucerna_mcp/__main__.py
ADDED
|
@@ -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
|