osidb-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,8 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ .pytest_cache/
5
+ .mypy_cache/
6
+ .ruff_cache/
7
+ dist/
8
+ *.egg-info/
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - Initial release: stdio MCP server, `readonly` / `readwrite` access mode (read tools only; mutations reserved for a later release), OSIDB session via `osidb-bindings` (Kerberos or basic auth).
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 osidb-mcp contributors
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,109 @@
1
+ Metadata-Version: 2.4
2
+ Name: osidb-mcp
3
+ Version: 0.1.0
4
+ Summary: Model Context Protocol (MCP) server for OSIDB using osidb-bindings
5
+ Project-URL: Homepage, https://github.com/RedHatProductSecurity/osidb
6
+ Project-URL: Repository, https://github.com/RedHatProductSecurity/osidb-bindings
7
+ Author: osidb-mcp contributors
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: cve,kerberos,mcp,osidb,security
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Information Technology
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Security
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: mcp<2,>=1.0
22
+ Requires-Dist: osidb-bindings<6,>=5.10.0
23
+ Provides-Extra: dev
24
+ Requires-Dist: pip-audit>=2.7; extra == 'dev'
25
+ Requires-Dist: pytest>=8.0; extra == 'dev'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # osidb-mcp
29
+
30
+ Python [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) server for [OSIDB](https://github.com/RedHatProductSecurity/osidb), built on [`osidb-bindings`](https://github.com/RedHatProductSecurity/osidb-bindings) from PyPI. Use it from Cursor, Claude Desktop, or any MCP client over **stdio**.
31
+
32
+ ## Install
33
+
34
+ ```bash
35
+ pipx install osidb-mcp
36
+ # or
37
+ pip install osidb-mcp
38
+ ```
39
+
40
+ ## Configuration (environment)
41
+
42
+ | Variable | Required | Description |
43
+ |----------|----------|-------------|
44
+ | `OSIDB_BASE_URL` | yes | OSIDB root URL, e.g. `https://osidb.example.com` |
45
+ | `OSIDB_AUTH` | no | `kerberos` (default) or `basic` |
46
+ | `OSIDB_USERNAME` / `OSIDB_PASSWORD` | for `basic` | Basic auth for token obtain |
47
+ | `OSIDB_VERIFY_SSL` | no | `true` (default) or `false` (prefer `REQUESTS_CA_BUNDLE` for custom CAs) |
48
+ | `OSIDB_USER_AGENT` | no | Optional extra User-Agent suffix |
49
+ | `OSIDB_MCP_ACCESS_MODE` | no | `readonly` (default) or `readwrite` (mutations reserved for a future release) |
50
+
51
+ Kerberos: the process must have a valid ticket (`kinit`) for the OSIDB HTTP service.
52
+
53
+ Optional keys forwarded by bindings: `BUGZILLA_API_KEY`, `JIRA_ACCESS_TOKEN`, `JIRA_API_EMAIL`.
54
+
55
+ ## Cursor / Claude MCP snippet
56
+
57
+ ```json
58
+ {
59
+ "mcpServers": {
60
+ "osidb": {
61
+ "command": "osidb-mcp",
62
+ "env": {
63
+ "OSIDB_BASE_URL": "https://your-internal-osidb",
64
+ "OSIDB_AUTH": "kerberos",
65
+ "OSIDB_VERIFY_SSL": "true",
66
+ "OSIDB_MCP_ACCESS_MODE": "readonly"
67
+ }
68
+ }
69
+ }
70
+ }
71
+ ```
72
+
73
+ ## Tools (read-only)
74
+
75
+ - `osidb_status` — health / version style status
76
+ - `osidb_whoami` — `/osidb/whoami`
77
+ - `flaw_get` — single flaw with optional `include_fields` / `exclude_fields`
78
+ - `flaws_list` / `flaws_count` — filter by `components_in`, `affects_ps_module_in`, `workflow_state_in`, `impact` / `impact_in`, `owner_isempty`, embargo flags, ISO8601 `changed_after` / `changed_before`, plus allowlisted `extra_query` keys from the OSIDB OpenAPI
79
+ - `flaws_search` — full-text `search`
80
+ - `affects_list` — affect-centric rows with `ps_module` / `ps_component` / `ps_update_stream` and `flaw__*` filters
81
+ - `trackers_list` — tracker filings with CVE / module / component filters
82
+ - `flaw_comments_list`, `flaw_references_list`, `flaw_cvss_scores_list`
83
+
84
+ `limit` is capped at **100** per request.
85
+
86
+ ## Analyst examples
87
+
88
+ - **Critical open flaws touching `httpd`:** `flaws_list` with `impact="CRITICAL"`, `workflow_state_in` set to the non-terminal states your instance uses, and `components_in=["httpd"]` or `affects_ps_component="httpd"` depending on how data is modeled.
89
+ - **Unowned important CVEs for a RHEL major:** `flaws_list` with `owner_isempty=true`, `impact_in=["IMPORTANT"]`, and `affects_ps_module_in` / `affects_ps_update_stream_in` set to the **exact** PS strings your OSIDB uses for that major (confirm in your internal docs).
90
+
91
+ ## Security
92
+
93
+ - Outputs may include **embargoed** content; treat transcripts and logs according to your data classification policy.
94
+ - Prefer `readonly` (default). `readwrite` does not enable mutations yet but is reserved for explicit future write tools.
95
+ - Never commit `OSIDB_PASSWORD`; use IDE env or secret stores.
96
+
97
+ ## Development
98
+
99
+ ```bash
100
+ uv venv .venv && source .venv/bin/activate
101
+ uv pip install -e ".[dev]"
102
+ python -m osidb_mcp
103
+ pytest
104
+ pip-audit
105
+ ```
106
+
107
+ ## License
108
+
109
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,82 @@
1
+ # osidb-mcp
2
+
3
+ Python [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) server for [OSIDB](https://github.com/RedHatProductSecurity/osidb), built on [`osidb-bindings`](https://github.com/RedHatProductSecurity/osidb-bindings) from PyPI. Use it from Cursor, Claude Desktop, or any MCP client over **stdio**.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pipx install osidb-mcp
9
+ # or
10
+ pip install osidb-mcp
11
+ ```
12
+
13
+ ## Configuration (environment)
14
+
15
+ | Variable | Required | Description |
16
+ |----------|----------|-------------|
17
+ | `OSIDB_BASE_URL` | yes | OSIDB root URL, e.g. `https://osidb.example.com` |
18
+ | `OSIDB_AUTH` | no | `kerberos` (default) or `basic` |
19
+ | `OSIDB_USERNAME` / `OSIDB_PASSWORD` | for `basic` | Basic auth for token obtain |
20
+ | `OSIDB_VERIFY_SSL` | no | `true` (default) or `false` (prefer `REQUESTS_CA_BUNDLE` for custom CAs) |
21
+ | `OSIDB_USER_AGENT` | no | Optional extra User-Agent suffix |
22
+ | `OSIDB_MCP_ACCESS_MODE` | no | `readonly` (default) or `readwrite` (mutations reserved for a future release) |
23
+
24
+ Kerberos: the process must have a valid ticket (`kinit`) for the OSIDB HTTP service.
25
+
26
+ Optional keys forwarded by bindings: `BUGZILLA_API_KEY`, `JIRA_ACCESS_TOKEN`, `JIRA_API_EMAIL`.
27
+
28
+ ## Cursor / Claude MCP snippet
29
+
30
+ ```json
31
+ {
32
+ "mcpServers": {
33
+ "osidb": {
34
+ "command": "osidb-mcp",
35
+ "env": {
36
+ "OSIDB_BASE_URL": "https://your-internal-osidb",
37
+ "OSIDB_AUTH": "kerberos",
38
+ "OSIDB_VERIFY_SSL": "true",
39
+ "OSIDB_MCP_ACCESS_MODE": "readonly"
40
+ }
41
+ }
42
+ }
43
+ }
44
+ ```
45
+
46
+ ## Tools (read-only)
47
+
48
+ - `osidb_status` — health / version style status
49
+ - `osidb_whoami` — `/osidb/whoami`
50
+ - `flaw_get` — single flaw with optional `include_fields` / `exclude_fields`
51
+ - `flaws_list` / `flaws_count` — filter by `components_in`, `affects_ps_module_in`, `workflow_state_in`, `impact` / `impact_in`, `owner_isempty`, embargo flags, ISO8601 `changed_after` / `changed_before`, plus allowlisted `extra_query` keys from the OSIDB OpenAPI
52
+ - `flaws_search` — full-text `search`
53
+ - `affects_list` — affect-centric rows with `ps_module` / `ps_component` / `ps_update_stream` and `flaw__*` filters
54
+ - `trackers_list` — tracker filings with CVE / module / component filters
55
+ - `flaw_comments_list`, `flaw_references_list`, `flaw_cvss_scores_list`
56
+
57
+ `limit` is capped at **100** per request.
58
+
59
+ ## Analyst examples
60
+
61
+ - **Critical open flaws touching `httpd`:** `flaws_list` with `impact="CRITICAL"`, `workflow_state_in` set to the non-terminal states your instance uses, and `components_in=["httpd"]` or `affects_ps_component="httpd"` depending on how data is modeled.
62
+ - **Unowned important CVEs for a RHEL major:** `flaws_list` with `owner_isempty=true`, `impact_in=["IMPORTANT"]`, and `affects_ps_module_in` / `affects_ps_update_stream_in` set to the **exact** PS strings your OSIDB uses for that major (confirm in your internal docs).
63
+
64
+ ## Security
65
+
66
+ - Outputs may include **embargoed** content; treat transcripts and logs according to your data classification policy.
67
+ - Prefer `readonly` (default). `readwrite` does not enable mutations yet but is reserved for explicit future write tools.
68
+ - Never commit `OSIDB_PASSWORD`; use IDE env or secret stores.
69
+
70
+ ## Development
71
+
72
+ ```bash
73
+ uv venv .venv && source .venv/bin/activate
74
+ uv pip install -e ".[dev]"
75
+ python -m osidb_mcp
76
+ pytest
77
+ pip-audit
78
+ ```
79
+
80
+ ## License
81
+
82
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,45 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.24"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "osidb-mcp"
7
+ version = "0.1.0"
8
+ description = "Model Context Protocol (MCP) server for OSIDB using osidb-bindings"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.10"
13
+ authors = [{ name = "osidb-mcp contributors" }]
14
+ keywords = ["mcp", "osidb", "security", "cve", "kerberos"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Intended Audience :: Information Technology",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Topic :: Security",
25
+ ]
26
+ dependencies = [
27
+ "mcp>=1.0,<2",
28
+ "osidb-bindings>=5.10.0,<6",
29
+ ]
30
+
31
+ [project.optional-dependencies]
32
+ dev = ["pytest>=8.0", "pip-audit>=2.7"]
33
+
34
+ [project.scripts]
35
+ osidb-mcp = "osidb_mcp.__main__:main"
36
+
37
+ [project.urls]
38
+ Homepage = "https://github.com/RedHatProductSecurity/osidb"
39
+ Repository = "https://github.com/RedHatProductSecurity/osidb-bindings"
40
+
41
+ [tool.hatch.build.targets.sdist]
42
+ include = ["src/osidb_mcp", "README.md", "LICENSE", "CHANGELOG.md"]
43
+
44
+ [tool.pytest.ini_options]
45
+ testpaths = ["tests"]
@@ -0,0 +1,3 @@
1
+ """MCP server bridging Model Context Protocol clients to OSIDB via osidb-bindings."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,37 @@
1
+ """Console entrypoint: ``osidb-mcp`` and ``python -m osidb_mcp``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import sys
7
+
8
+ from osidb_mcp.config import AccessMode, load_settings
9
+ from osidb_mcp.server import create_server
10
+ from osidb_mcp.session_holder import init_session
11
+
12
+
13
+ def main() -> None:
14
+ logging.basicConfig(
15
+ level=logging.INFO,
16
+ format="%(levelname)s %(name)s: %(message)s",
17
+ stream=sys.stderr,
18
+ )
19
+ log = logging.getLogger("osidb_mcp")
20
+
21
+ settings = load_settings()
22
+ init_session(settings)
23
+
24
+ if settings.access_mode == AccessMode.readwrite:
25
+ log.warning(
26
+ "OSIDB_MCP_ACCESS_MODE=readwrite — mutation MCP tools are not implemented yet; "
27
+ "only read tools are registered."
28
+ )
29
+ else:
30
+ log.info("osidb-mcp access mode: readonly")
31
+
32
+ mcp = create_server(settings)
33
+ mcp.run(transport="stdio")
34
+
35
+
36
+ if __name__ == "__main__":
37
+ main()
@@ -0,0 +1,80 @@
1
+ """Load configuration from the environment (no secrets in source code)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+ from enum import Enum
8
+ from typing import Literal
9
+
10
+
11
+ class AccessMode(str, Enum):
12
+ """Server capability gate: only ``readwrite`` may register mutation tools (future)."""
13
+
14
+ readonly = "readonly"
15
+ readwrite = "readwrite"
16
+
17
+
18
+ def _env_bool(name: str, default: bool) -> bool:
19
+ raw = os.environ.get(name)
20
+ if raw is None or raw == "":
21
+ return default
22
+ return raw.strip().lower() in {"1", "true", "yes", "y", "on"}
23
+
24
+
25
+ def _parse_access_mode() -> AccessMode:
26
+ raw = (os.environ.get("OSIDB_MCP_ACCESS_MODE") or "readonly").strip().lower()
27
+ if raw == "readonly":
28
+ return AccessMode.readonly
29
+ if raw == "readwrite":
30
+ return AccessMode.readwrite
31
+ raise ValueError(
32
+ "OSIDB_MCP_ACCESS_MODE must be 'readonly' or 'readwrite', "
33
+ f"got {raw!r}"
34
+ )
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Settings:
39
+ base_url: str
40
+ auth: Literal["kerberos", "basic"]
41
+ username: str | None
42
+ password: str | None
43
+ verify_ssl: bool
44
+ user_agent: str | None
45
+ access_mode: AccessMode
46
+
47
+
48
+ def load_settings() -> Settings:
49
+ base = os.environ.get("OSIDB_BASE_URL", "").strip()
50
+ if not base:
51
+ raise ValueError("OSIDB_BASE_URL is required")
52
+
53
+ auth = (os.environ.get("OSIDB_AUTH") or "kerberos").strip().lower()
54
+ if auth not in ("kerberos", "basic"):
55
+ raise ValueError("OSIDB_AUTH must be 'kerberos' or 'basic'")
56
+
57
+ username = os.environ.get("OSIDB_USERNAME")
58
+ password = os.environ.get("OSIDB_PASSWORD")
59
+ if auth == "basic":
60
+ if not username or not password:
61
+ raise ValueError(
62
+ "OSIDB_AUTH=basic requires OSIDB_USERNAME and OSIDB_PASSWORD"
63
+ )
64
+ else:
65
+ username = None
66
+ password = None
67
+
68
+ ua = os.environ.get("OSIDB_USER_AGENT")
69
+ if ua == "":
70
+ ua = None
71
+
72
+ return Settings(
73
+ base_url=base,
74
+ auth=auth, # type: ignore[arg-type]
75
+ username=username,
76
+ password=password,
77
+ verify_ssl=_env_bool("OSIDB_VERIFY_SSL", True),
78
+ user_agent=ua,
79
+ access_mode=_parse_access_mode(),
80
+ )
@@ -0,0 +1,20 @@
1
+ """Format HTTP errors without leaking secrets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import requests
8
+
9
+
10
+ def http_error_payload(exc: BaseException) -> dict[str, Any]:
11
+ if isinstance(exc, requests.HTTPError):
12
+ resp = exc.response
13
+ status = resp.status_code if resp is not None else None
14
+ text = (resp.text[:2000] if resp is not None else "") or ""
15
+ return {
16
+ "error": "osidb_http_error",
17
+ "status_code": status,
18
+ "detail": text,
19
+ }
20
+ return {"error": "osidb_error", "detail": str(exc)}
@@ -0,0 +1,69 @@
1
+ """Build kwargs for flaws/affects list with allowlisted ``extra_query``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from osidb_bindings.bindings.python_client.api.osidb import (
8
+ osidb_api_v2_affects_list,
9
+ osidb_api_v2_flaws_list,
10
+ )
11
+
12
+ FLAWS_EXTRA_KEYS = frozenset(osidb_api_v2_flaws_list.QUERY_PARAMS.keys())
13
+ AFFECTS_EXTRA_KEYS = frozenset(osidb_api_v2_affects_list.QUERY_PARAMS.keys())
14
+
15
+ EXTRAS_MAX_KEYS = 30
16
+ EXTRAS_MAX_LIST_LEN = 100
17
+ LIST_LIMIT_MAX = 100
18
+ DEFAULT_LIST_LIMIT = 50
19
+
20
+
21
+ def _coerce_extra_value(value: Any) -> Any:
22
+ if value is None or isinstance(value, (bool, int, float, str)):
23
+ return value
24
+ if isinstance(value, list):
25
+ if len(value) > EXTRAS_MAX_LIST_LEN:
26
+ raise ValueError(
27
+ f"extra_query list values must have at most {EXTRAS_MAX_LIST_LEN} items"
28
+ )
29
+ out = []
30
+ for item in value:
31
+ if not isinstance(item, (str, int, float, bool)):
32
+ raise ValueError("extra_query list items must be primitives")
33
+ out.append(item)
34
+ return out
35
+ raise ValueError("extra_query values must be primitives or lists of primitives")
36
+
37
+
38
+ def merge_extra_query(
39
+ base: dict[str, Any],
40
+ extra: dict[str, Any] | None,
41
+ *,
42
+ allowlist: frozenset[str],
43
+ ) -> dict[str, Any]:
44
+ if not extra:
45
+ return base
46
+ if len(extra) > EXTRAS_MAX_KEYS:
47
+ raise ValueError(
48
+ f"extra_query may contain at most {EXTRAS_MAX_KEYS} keys"
49
+ )
50
+ merged = dict(base)
51
+ for key, raw in extra.items():
52
+ if key not in allowlist:
53
+ raise ValueError(f"extra_query key not allowed: {key!r}")
54
+ merged[key] = _coerce_extra_value(raw)
55
+ return merged
56
+
57
+
58
+ def clamp_limit(limit: int | None) -> int:
59
+ if limit is None:
60
+ return DEFAULT_LIST_LIMIT
61
+ if limit < 1:
62
+ return 1
63
+ return min(limit, LIST_LIMIT_MAX)
64
+
65
+
66
+ def clamp_offset(offset: int | None) -> int:
67
+ if offset is None or offset < 0:
68
+ return 0
69
+ return offset
@@ -0,0 +1,49 @@
1
+ """JSON-friendly serialization for bindings models and responses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime
6
+ from enum import Enum
7
+ from typing import Any
8
+ from uuid import UUID
9
+
10
+
11
+ def to_jsonable(obj: Any) -> Any:
12
+ if obj is None:
13
+ return None
14
+ if isinstance(obj, (str, int, float, bool)):
15
+ return obj
16
+ if isinstance(obj, UUID):
17
+ return str(obj)
18
+ if isinstance(obj, Enum):
19
+ return obj.value
20
+ if isinstance(obj, (datetime.datetime, datetime.date)):
21
+ return obj.isoformat()
22
+ if isinstance(obj, dict):
23
+ return {str(k): to_jsonable(v) for k, v in obj.items()}
24
+ if isinstance(obj, (list, tuple)):
25
+ return [to_jsonable(x) for x in obj]
26
+ if hasattr(obj, "to_dict"):
27
+ return to_jsonable(obj.to_dict())
28
+ return str(obj)
29
+
30
+
31
+ def paginated_summary(
32
+ response: Any,
33
+ *,
34
+ limit: int,
35
+ offset: int,
36
+ ) -> dict[str, Any]:
37
+ """Common pagination envelope for list endpoints."""
38
+ results = getattr(response, "results", ()) or ()
39
+ out: dict[str, Any] = {
40
+ "count": getattr(response, "count", None),
41
+ "limit": limit,
42
+ "offset": offset,
43
+ "results": [to_jsonable(r) for r in results],
44
+ }
45
+ next_url = getattr(response, "next_", None)
46
+ if next_url:
47
+ out["next"] = next_url
48
+ out["next_offset"] = offset + len(results)
49
+ return out
@@ -0,0 +1,89 @@
1
+ """FastMCP application wiring (stdio)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+
7
+ from mcp.server.fastmcp import FastMCP
8
+
9
+ from osidb_mcp.config import AccessMode, Settings
10
+ from osidb_mcp import tools_read
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ _INSTRUCTIONS = """\
15
+ This server exposes read-only OSIDB operations (flaws/CVEs, affects, trackers, comments, references, CVSS) \
16
+ via official osidb-bindings. Responses may include embargoed data depending on your OSIDB account.
17
+
18
+ Use ``flaws_list`` / ``flaws_count`` with ``components_in``, ``affects_ps_component``, ``affects_ps_module_in``, \
19
+ ``workflow_state_in``, ``impact`` / ``impact_in``, and ``owner_isempty`` for common triage queries. \
20
+ Use ``affects_list`` when you want rows keyed by affect (``ps_module`` / ``ps_component`` / ``ps_update_stream``) \
21
+ with ``flaw__*`` filters. Workflow state strings are deployment-specific.
22
+
23
+ Access mode is controlled only by the ``OSIDB_MCP_ACCESS_MODE`` environment variable (``readonly`` default; \
24
+ ``readwrite`` reserved for future mutation tools).
25
+ """
26
+
27
+
28
+ def create_server(settings: Settings) -> FastMCP:
29
+ mcp = FastMCP("osidb-mcp", instructions=_INSTRUCTIONS)
30
+
31
+ mcp.tool(name="osidb_status", description="OSIDB API health / status payload.")(
32
+ tools_read.osidb_status
33
+ )
34
+ mcp.tool(
35
+ name="osidb_whoami",
36
+ description="Current authenticated OSIDB user / profile (from /osidb/whoami).",
37
+ )(tools_read.osidb_whoami)
38
+ mcp.tool(
39
+ name="flaw_get",
40
+ description="Retrieve a single flaw by CVE id or flaw id; optional field projection.",
41
+ )(tools_read.flaw_get)
42
+ mcp.tool(
43
+ name="flaws_list",
44
+ description=(
45
+ "List flaws with filters: components, affects (ps_module/ps_component/ps_update_stream), "
46
+ "workflow_state, impact, owner_isempty, embargoed, dates, etc. "
47
+ "Optional ``extra_query`` must use OSIDB v2 list query keys (allowlisted). "
48
+ "limit is capped at 100."
49
+ ),
50
+ )(tools_read.flaws_list)
51
+ mcp.tool(
52
+ name="flaws_count",
53
+ description="Count flaws matching the same filters as flaws_list (no result bodies).",
54
+ )(tools_read.flaws_count)
55
+ mcp.tool(
56
+ name="flaws_search",
57
+ description="Full-text search flaws (maps to OSIDB search parameter).",
58
+ )(tools_read.flaws_search)
59
+ mcp.tool(
60
+ name="affects_list",
61
+ description=(
62
+ "List affects with ps_module / ps_component / ps_update_stream and flaw__ filters "
63
+ "(e.g. flaw_workflow_state_in, flaw_impact_in, flaw_components_in)."
64
+ ),
65
+ )(tools_read.affects_list)
66
+ mcp.tool(
67
+ name="trackers_list",
68
+ description="List trackers (filings) with optional CVE / ps_module / ps_component filters.",
69
+ )(tools_read.trackers_list)
70
+ mcp.tool(
71
+ name="flaw_comments_list",
72
+ description="Paginated comments for a flaw id.",
73
+ )(tools_read.flaw_comments_list)
74
+ mcp.tool(
75
+ name="flaw_references_list",
76
+ description="Paginated external references for a flaw id.",
77
+ )(tools_read.flaw_references_list)
78
+ mcp.tool(
79
+ name="flaw_cvss_scores_list",
80
+ description="Paginated CVSS score rows for a flaw id.",
81
+ )(tools_read.flaw_cvss_scores_list)
82
+
83
+ if settings.access_mode == AccessMode.readwrite:
84
+ logger.warning(
85
+ "readwrite mode requested: mutation tools are not implemented in this release; "
86
+ "only read tools are registered."
87
+ )
88
+
89
+ return mcp
@@ -0,0 +1,42 @@
1
+ """Lazy OSIDB session (JWT refresh handled by osidb-bindings)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import osidb_bindings
6
+ from osidb_bindings.session import Session
7
+
8
+ from osidb_mcp.config import Settings
9
+
10
+ _session: Session | None = None
11
+ _settings: Settings | None = None
12
+
13
+
14
+ def init_session(settings: Settings) -> None:
15
+ global _session, _settings
16
+ _settings = settings
17
+ if settings.auth == "basic":
18
+ _session = osidb_bindings.new_session(
19
+ osidb_server_uri=settings.base_url,
20
+ username=settings.username,
21
+ password=settings.password,
22
+ verify_ssl=settings.verify_ssl,
23
+ user_agent=settings.user_agent,
24
+ )
25
+ else:
26
+ _session = osidb_bindings.new_session(
27
+ osidb_server_uri=settings.base_url,
28
+ verify_ssl=settings.verify_ssl,
29
+ user_agent=settings.user_agent,
30
+ )
31
+
32
+
33
+ def get_session() -> Session:
34
+ if _session is None:
35
+ raise RuntimeError("Session not initialized")
36
+ return _session
37
+
38
+
39
+ def current_settings() -> Settings:
40
+ if _settings is None:
41
+ raise RuntimeError("Session not initialized")
42
+ return _settings
@@ -0,0 +1,622 @@
1
+ """MCP tool implementations (read-only OSIDB operations)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime
6
+ import importlib
7
+ from typing import Any
8
+
9
+ import requests
10
+ from osidb_bindings.bindings.python_client.api.osidb import osidb_whoami_retrieve
11
+ from osidb_bindings.bindings.python_client.models.osidb_api_v2_affects_list_flaw_impact import (
12
+ OsidbApiV2AffectsListFlawImpact,
13
+ )
14
+ from osidb_bindings.bindings.python_client.models.osidb_api_v2_affects_list_flaw_impact_in_item import (
15
+ OsidbApiV2AffectsListFlawImpactInItem,
16
+ )
17
+ from osidb_bindings.bindings.python_client.models.osidb_api_v2_affects_list_flaw_workflow_state_item import (
18
+ OsidbApiV2AffectsListFlawWorkflowStateItem,
19
+ )
20
+ from osidb_bindings.bindings.python_client.models.osidb_api_v2_flaws_list_impact import (
21
+ OsidbApiV2FlawsListImpact,
22
+ )
23
+ from osidb_bindings.bindings.python_client.models.osidb_api_v2_flaws_list_impact_in_item import (
24
+ OsidbApiV2FlawsListImpactInItem,
25
+ )
26
+ from osidb_bindings.bindings.python_client.models.osidb_api_v2_flaws_list_major_incident_state import (
27
+ OsidbApiV2FlawsListMajorIncidentState,
28
+ )
29
+ from osidb_bindings.bindings.python_client.models.osidb_api_v2_flaws_list_source import (
30
+ OsidbApiV2FlawsListSource,
31
+ )
32
+ from osidb_bindings.bindings.python_client.models.osidb_api_v2_flaws_list_workflow_state_item import (
33
+ OsidbApiV2FlawsListWorkflowStateItem,
34
+ )
35
+
36
+ from osidb_mcp.errors import http_error_payload
37
+ from osidb_mcp.query_filters import (
38
+ AFFECTS_EXTRA_KEYS,
39
+ DEFAULT_LIST_LIMIT,
40
+ FLAWS_EXTRA_KEYS,
41
+ clamp_limit,
42
+ clamp_offset,
43
+ merge_extra_query,
44
+ )
45
+ from osidb_mcp.serialize import paginated_summary, to_jsonable
46
+ from osidb_mcp.session_holder import get_session
47
+
48
+ _trackers_list = importlib.import_module(
49
+ "osidb_bindings.bindings.python_client.api.osidb.osidb_api_v2_trackers_list"
50
+ )
51
+ TRACKERS_EXTRA_KEYS = frozenset(_trackers_list.QUERY_PARAMS.keys())
52
+
53
+
54
+ def _parse_dt(value: str | None) -> datetime.datetime | None:
55
+ if not value:
56
+ return None
57
+ s = value.strip()
58
+ if s.endswith("Z"):
59
+ s = s[:-1] + "+00:00:00"
60
+ return datetime.datetime.fromisoformat(s)
61
+
62
+
63
+ def _impact_in(values: list[str] | None) -> list[OsidbApiV2FlawsListImpactInItem] | None:
64
+ if not values:
65
+ return None
66
+ return [OsidbApiV2FlawsListImpactInItem(v) for v in values]
67
+
68
+
69
+ def _workflow_in(
70
+ values: list[str] | None,
71
+ ) -> list[OsidbApiV2FlawsListWorkflowStateItem] | None:
72
+ if not values:
73
+ return None
74
+ return [OsidbApiV2FlawsListWorkflowStateItem(v) for v in values]
75
+
76
+
77
+ def osidb_status() -> dict[str, Any]:
78
+ try:
79
+ st = get_session().status()
80
+ return {"ok": True, "status": to_jsonable(st)}
81
+ except requests.RequestException as e:
82
+ return {"ok": False, **http_error_payload(e)}
83
+
84
+
85
+ def osidb_whoami() -> dict[str, Any]:
86
+ try:
87
+ s = get_session()
88
+ r = osidb_whoami_retrieve.sync_detailed(
89
+ client=s.get_client_with_new_access_token()
90
+ )
91
+ if r.parsed is None:
92
+ return {
93
+ "ok": False,
94
+ "error": "whoami_empty",
95
+ "status_code": int(r.status_code),
96
+ }
97
+ return {"ok": True, "whoami": to_jsonable(r.parsed.to_dict())}
98
+ except requests.RequestException as e:
99
+ return {"ok": False, **http_error_payload(e)}
100
+
101
+
102
+ def flaw_get(
103
+ flaw_id: str,
104
+ include_fields: list[str] | None = None,
105
+ exclude_fields: list[str] | None = None,
106
+ api_version: str | None = None,
107
+ ) -> dict[str, Any]:
108
+ try:
109
+ kw: dict[str, Any] = {}
110
+ if include_fields:
111
+ kw["include_fields"] = include_fields
112
+ if exclude_fields:
113
+ kw["exclude_fields"] = exclude_fields
114
+ flaw = get_session().flaws.retrieve(
115
+ flaw_id, api_version=api_version, **kw
116
+ )
117
+ return {"ok": True, "flaw": to_jsonable(flaw)}
118
+ except requests.RequestException as e:
119
+ return {"ok": False, **http_error_payload(e)}
120
+
121
+
122
+ def _build_flaws_kwargs(
123
+ *,
124
+ search: str | None,
125
+ embargoed: bool | None,
126
+ components: list[str] | None,
127
+ components_in: list[str] | None,
128
+ affects_ps_module: str | None,
129
+ affects_ps_module_in: list[str] | None,
130
+ affects_ps_component: str | None,
131
+ affects_ps_component_in: list[str] | None,
132
+ affects_ps_update_stream: str | None,
133
+ affects_ps_update_stream_in: list[str] | None,
134
+ workflow_state: list[str] | None,
135
+ workflow_state_in: list[str] | None,
136
+ impact: str | None,
137
+ impact_in: list[str] | None,
138
+ owner: str | None,
139
+ owner_in: list[str] | None,
140
+ owner_isempty: bool | None,
141
+ cve_id_in: list[str] | None,
142
+ changed_after: str | None,
143
+ changed_before: str | None,
144
+ major_incident_state: str | None,
145
+ source: str | None,
146
+ source_in: list[str] | None,
147
+ include_fields: list[str] | None,
148
+ exclude_fields: list[str] | None,
149
+ limit: int,
150
+ offset: int,
151
+ ) -> dict[str, Any]:
152
+ kw: dict[str, Any] = {"limit": limit, "offset": offset}
153
+ if search:
154
+ kw["search"] = search
155
+ if embargoed is not None:
156
+ kw["embargoed"] = embargoed
157
+ if components:
158
+ kw["components"] = components
159
+ if components_in:
160
+ kw["components__in"] = components_in
161
+ if affects_ps_module:
162
+ kw["affects__ps_module"] = affects_ps_module
163
+ if affects_ps_module_in:
164
+ kw["affects__ps_module__in"] = affects_ps_module_in
165
+ if affects_ps_component:
166
+ kw["affects__ps_component"] = affects_ps_component
167
+ if affects_ps_component_in:
168
+ kw["affects__ps_component__in"] = affects_ps_component_in
169
+ if affects_ps_update_stream:
170
+ kw["affects__ps_update_stream"] = affects_ps_update_stream
171
+ if affects_ps_update_stream_in:
172
+ kw["affects__ps_update_stream__in"] = affects_ps_update_stream_in
173
+ if workflow_state:
174
+ kw["workflow_state"] = _workflow_in(workflow_state)
175
+ if workflow_state_in:
176
+ kw["workflow_state__in"] = _workflow_in(workflow_state_in)
177
+ if impact:
178
+ kw["impact"] = OsidbApiV2FlawsListImpact(impact)
179
+ if impact_in:
180
+ kw["impact__in"] = _impact_in(impact_in)
181
+ if owner:
182
+ kw["owner"] = owner
183
+ if owner_in:
184
+ kw["owner__in"] = owner_in
185
+ if owner_isempty is not None:
186
+ kw["owner__isempty"] = owner_isempty
187
+ if cve_id_in:
188
+ kw["cve_id__in"] = cve_id_in
189
+ ca, cb = _parse_dt(changed_after), _parse_dt(changed_before)
190
+ if ca is not None:
191
+ kw["changed_after"] = ca
192
+ if cb is not None:
193
+ kw["changed_before"] = cb
194
+ if major_incident_state:
195
+ kw["major_incident_state"] = OsidbApiV2FlawsListMajorIncidentState(
196
+ major_incident_state
197
+ )
198
+ if source:
199
+ kw["source"] = OsidbApiV2FlawsListSource(source)
200
+ if source_in:
201
+ kw["source__in"] = [OsidbApiV2FlawsListSource(s) for s in source_in]
202
+ if include_fields:
203
+ kw["include_fields"] = include_fields
204
+ if exclude_fields:
205
+ kw["exclude_fields"] = exclude_fields
206
+ return kw
207
+
208
+
209
+ def flaws_list(
210
+ *,
211
+ search: str | None = None,
212
+ embargoed: bool | None = None,
213
+ components: list[str] | None = None,
214
+ components_in: list[str] | None = None,
215
+ affects_ps_module: str | None = None,
216
+ affects_ps_module_in: list[str] | None = None,
217
+ affects_ps_component: str | None = None,
218
+ affects_ps_component_in: list[str] | None = None,
219
+ affects_ps_update_stream: str | None = None,
220
+ affects_ps_update_stream_in: list[str] | None = None,
221
+ workflow_state: list[str] | None = None,
222
+ workflow_state_in: list[str] | None = None,
223
+ impact: str | None = None,
224
+ impact_in: list[str] | None = None,
225
+ owner: str | None = None,
226
+ owner_in: list[str] | None = None,
227
+ owner_isempty: bool | None = None,
228
+ cve_id_in: list[str] | None = None,
229
+ changed_after: str | None = None,
230
+ changed_before: str | None = None,
231
+ major_incident_state: str | None = None,
232
+ source: str | None = None,
233
+ source_in: list[str] | None = None,
234
+ include_fields: list[str] | None = None,
235
+ exclude_fields: list[str] | None = None,
236
+ limit: int = DEFAULT_LIST_LIMIT,
237
+ offset: int = 0,
238
+ api_version: str | None = None,
239
+ extra_query: dict[str, Any] | None = None,
240
+ ) -> dict[str, Any]:
241
+ lim = clamp_limit(limit)
242
+ off = clamp_offset(offset)
243
+ try:
244
+ base = _build_flaws_kwargs(
245
+ search=search,
246
+ embargoed=embargoed,
247
+ components=components,
248
+ components_in=components_in,
249
+ affects_ps_module=affects_ps_module,
250
+ affects_ps_module_in=affects_ps_module_in,
251
+ affects_ps_component=affects_ps_component,
252
+ affects_ps_component_in=affects_ps_component_in,
253
+ affects_ps_update_stream=affects_ps_update_stream,
254
+ affects_ps_update_stream_in=affects_ps_update_stream_in,
255
+ workflow_state=workflow_state,
256
+ workflow_state_in=workflow_state_in,
257
+ impact=impact,
258
+ impact_in=impact_in,
259
+ owner=owner,
260
+ owner_in=owner_in,
261
+ owner_isempty=owner_isempty,
262
+ cve_id_in=cve_id_in,
263
+ changed_after=changed_after,
264
+ changed_before=changed_before,
265
+ major_incident_state=major_incident_state,
266
+ source=source,
267
+ source_in=source_in,
268
+ include_fields=include_fields,
269
+ exclude_fields=exclude_fields,
270
+ limit=lim,
271
+ offset=off,
272
+ )
273
+ merged = merge_extra_query(base, extra_query, allowlist=FLAWS_EXTRA_KEYS)
274
+ resp = get_session().flaws.retrieve_list(
275
+ api_version=api_version,
276
+ **merged,
277
+ )
278
+ return {
279
+ "ok": True,
280
+ **paginated_summary(resp, limit=lim, offset=off),
281
+ }
282
+ except (requests.RequestException, ValueError) as e:
283
+ if isinstance(e, ValueError):
284
+ return {"ok": False, "error": "bad_request", "detail": str(e)}
285
+ return {"ok": False, **http_error_payload(e)}
286
+
287
+
288
+ def flaws_count(
289
+ *,
290
+ search: str | None = None,
291
+ embargoed: bool | None = None,
292
+ components: list[str] | None = None,
293
+ components_in: list[str] | None = None,
294
+ affects_ps_module: str | None = None,
295
+ affects_ps_module_in: list[str] | None = None,
296
+ affects_ps_component: str | None = None,
297
+ affects_ps_component_in: list[str] | None = None,
298
+ affects_ps_update_stream: str | None = None,
299
+ affects_ps_update_stream_in: list[str] | None = None,
300
+ workflow_state: list[str] | None = None,
301
+ workflow_state_in: list[str] | None = None,
302
+ impact: str | None = None,
303
+ impact_in: list[str] | None = None,
304
+ owner: str | None = None,
305
+ owner_in: list[str] | None = None,
306
+ owner_isempty: bool | None = None,
307
+ cve_id_in: list[str] | None = None,
308
+ changed_after: str | None = None,
309
+ changed_before: str | None = None,
310
+ major_incident_state: str | None = None,
311
+ source: str | None = None,
312
+ source_in: list[str] | None = None,
313
+ api_version: str | None = None,
314
+ extra_query: dict[str, Any] | None = None,
315
+ ) -> dict[str, Any]:
316
+ try:
317
+ base = _build_flaws_kwargs(
318
+ search=search,
319
+ embargoed=embargoed,
320
+ components=components,
321
+ components_in=components_in,
322
+ affects_ps_module=affects_ps_module,
323
+ affects_ps_module_in=affects_ps_module_in,
324
+ affects_ps_component=affects_ps_component,
325
+ affects_ps_component_in=affects_ps_component_in,
326
+ affects_ps_update_stream=affects_ps_update_stream,
327
+ affects_ps_update_stream_in=affects_ps_update_stream_in,
328
+ workflow_state=workflow_state,
329
+ workflow_state_in=workflow_state_in,
330
+ impact=impact,
331
+ impact_in=impact_in,
332
+ owner=owner,
333
+ owner_in=owner_in,
334
+ owner_isempty=owner_isempty,
335
+ cve_id_in=cve_id_in,
336
+ changed_after=changed_after,
337
+ changed_before=changed_before,
338
+ major_incident_state=major_incident_state,
339
+ source=source,
340
+ source_in=source_in,
341
+ include_fields=None,
342
+ exclude_fields=None,
343
+ limit=50,
344
+ offset=0,
345
+ )
346
+ base.pop("limit", None)
347
+ base.pop("offset", None)
348
+ merged = merge_extra_query(base, extra_query, allowlist=FLAWS_EXTRA_KEYS)
349
+ n = get_session().flaws.count(api_version=api_version, **merged)
350
+ return {"ok": True, "count": n}
351
+ except (requests.RequestException, ValueError) as e:
352
+ if isinstance(e, ValueError):
353
+ return {"ok": False, "error": "bad_request", "detail": str(e)}
354
+ return {"ok": False, **http_error_payload(e)}
355
+
356
+
357
+ def flaws_search(
358
+ text: str,
359
+ limit: int = DEFAULT_LIST_LIMIT,
360
+ api_version: str | None = None,
361
+ ) -> dict[str, Any]:
362
+ lim = clamp_limit(limit)
363
+ try:
364
+ resp = get_session().flaws.search(
365
+ text,
366
+ api_version=api_version,
367
+ limit=lim,
368
+ )
369
+ return {
370
+ "ok": True,
371
+ **paginated_summary(resp, limit=lim, offset=0),
372
+ }
373
+ except requests.RequestException as e:
374
+ return {"ok": False, **http_error_payload(e)}
375
+
376
+
377
+ def _affects_impact_in(
378
+ values: list[str] | None,
379
+ ) -> list[OsidbApiV2AffectsListFlawImpactInItem] | None:
380
+ if not values:
381
+ return None
382
+ return [OsidbApiV2AffectsListFlawImpactInItem(v) for v in values]
383
+
384
+
385
+ def _affects_workflow_in(
386
+ values: list[str] | None,
387
+ ) -> list[OsidbApiV2AffectsListFlawWorkflowStateItem] | None:
388
+ if not values:
389
+ return None
390
+ return [OsidbApiV2AffectsListFlawWorkflowStateItem(v) for v in values]
391
+
392
+
393
+ def affects_list(
394
+ *,
395
+ ps_module: str | None = None,
396
+ ps_module_in: list[str] | None = None,
397
+ ps_component: str | None = None,
398
+ ps_component_in: list[str] | None = None,
399
+ ps_update_stream: str | None = None,
400
+ ps_update_stream_in: list[str] | None = None,
401
+ flaw_cve_id: str | None = None,
402
+ flaw_cve_id_in: list[str] | None = None,
403
+ flaw_workflow_state: list[str] | None = None,
404
+ flaw_workflow_state_in: list[str] | None = None,
405
+ flaw_impact: str | None = None,
406
+ flaw_impact_in: list[str] | None = None,
407
+ flaw_components: list[str] | None = None,
408
+ flaw_components_in: list[str] | None = None,
409
+ embargoed: bool | None = None,
410
+ include_fields: list[str] | None = None,
411
+ exclude_fields: list[str] | None = None,
412
+ limit: int = DEFAULT_LIST_LIMIT,
413
+ offset: int = 0,
414
+ api_version: str | None = None,
415
+ extra_query: dict[str, Any] | None = None,
416
+ ) -> dict[str, Any]:
417
+ lim = clamp_limit(limit)
418
+ off = clamp_offset(offset)
419
+ try:
420
+ kw: dict[str, Any] = {"limit": lim, "offset": off}
421
+ if ps_module:
422
+ kw["ps_module"] = ps_module
423
+ if ps_module_in:
424
+ kw["ps_module__in"] = ps_module_in
425
+ if ps_component:
426
+ kw["ps_component"] = ps_component
427
+ if ps_component_in:
428
+ kw["ps_component__in"] = ps_component_in
429
+ if ps_update_stream:
430
+ kw["ps_update_stream"] = ps_update_stream
431
+ if ps_update_stream_in:
432
+ kw["ps_update_stream__in"] = ps_update_stream_in
433
+ if flaw_cve_id:
434
+ kw["flaw__cve_id"] = flaw_cve_id
435
+ if flaw_cve_id_in:
436
+ kw["flaw__cve_id__in"] = flaw_cve_id_in
437
+ if flaw_workflow_state:
438
+ kw["flaw__workflow_state"] = _affects_workflow_in(flaw_workflow_state)
439
+ if flaw_workflow_state_in:
440
+ kw["flaw__workflow_state__in"] = _affects_workflow_in(
441
+ flaw_workflow_state_in
442
+ )
443
+ if flaw_impact:
444
+ kw["flaw__impact"] = OsidbApiV2AffectsListFlawImpact(flaw_impact)
445
+ if flaw_impact_in:
446
+ kw["flaw__impact__in"] = _affects_impact_in(flaw_impact_in)
447
+ if flaw_components:
448
+ kw["flaw__components"] = flaw_components
449
+ if flaw_components_in:
450
+ kw["flaw__components__in"] = flaw_components_in
451
+ if embargoed is not None:
452
+ kw["embargoed"] = embargoed
453
+ if include_fields:
454
+ kw["include_fields"] = include_fields
455
+ if exclude_fields:
456
+ kw["exclude_fields"] = exclude_fields
457
+ merged = merge_extra_query(kw, extra_query, allowlist=AFFECTS_EXTRA_KEYS)
458
+ resp = get_session().affects.retrieve_list(
459
+ api_version=api_version,
460
+ **merged,
461
+ )
462
+ return {
463
+ "ok": True,
464
+ **paginated_summary(resp, limit=lim, offset=off),
465
+ }
466
+ except (requests.RequestException, ValueError) as e:
467
+ if isinstance(e, ValueError):
468
+ return {"ok": False, "error": "bad_request", "detail": str(e)}
469
+ return {"ok": False, **http_error_payload(e)}
470
+
471
+
472
+ def _finalize_trackers_kwargs(merged: dict[str, Any]) -> dict[str, Any]:
473
+ """Map OpenAPI ``type`` query keys to the generated client ``type_`` kwargs."""
474
+ from osidb_bindings.bindings.python_client.models.osidb_api_v2_trackers_list_type import (
475
+ OsidbApiV2TrackersListType,
476
+ )
477
+ from osidb_bindings.bindings.python_client.models.osidb_api_v2_trackers_list_type_in_item import (
478
+ OsidbApiV2TrackersListTypeInItem,
479
+ )
480
+
481
+ out = dict(merged)
482
+ if "type" in out:
483
+ v = out.pop("type")
484
+ out["type_"] = (
485
+ OsidbApiV2TrackersListType(v) if isinstance(v, str) else v
486
+ )
487
+ if "type__in" in out:
488
+ v = out.pop("type__in")
489
+ if isinstance(v, list):
490
+ out["type_in"] = [
491
+ OsidbApiV2TrackersListTypeInItem(x) if isinstance(x, str) else x
492
+ for x in v
493
+ ]
494
+ else:
495
+ out["type_in"] = v
496
+ return out
497
+
498
+
499
+ def trackers_list(
500
+ *,
501
+ affects_flaw_cve_id: str | None = None,
502
+ affects_flaw_cve_id_in: list[str] | None = None,
503
+ affects_ps_module_in: list[str] | None = None,
504
+ affects_ps_component_in: list[str] | None = None,
505
+ tracker_type: str | None = None,
506
+ include_fields: list[str] | None = None,
507
+ exclude_fields: list[str] | None = None,
508
+ limit: int = DEFAULT_LIST_LIMIT,
509
+ offset: int = 0,
510
+ api_version: str | None = None,
511
+ extra_query: dict[str, Any] | None = None,
512
+ ) -> dict[str, Any]:
513
+ lim = clamp_limit(limit)
514
+ off = clamp_offset(offset)
515
+ try:
516
+ from osidb_bindings.bindings.python_client.models.osidb_api_v2_trackers_list_type import (
517
+ OsidbApiV2TrackersListType,
518
+ )
519
+
520
+ kw: dict[str, Any] = {"limit": lim, "offset": off}
521
+ if affects_flaw_cve_id:
522
+ kw["affects__flaw__cve_id"] = affects_flaw_cve_id
523
+ if affects_flaw_cve_id_in:
524
+ kw["affects__flaw__cve_id__in"] = affects_flaw_cve_id_in
525
+ if affects_ps_module_in:
526
+ kw["affects__ps_module__in"] = affects_ps_module_in
527
+ if affects_ps_component_in:
528
+ kw["affects__ps_component__in"] = affects_ps_component_in
529
+ if tracker_type:
530
+ kw["type_"] = OsidbApiV2TrackersListType(tracker_type)
531
+ if include_fields:
532
+ kw["include_fields"] = include_fields
533
+ if exclude_fields:
534
+ kw["exclude_fields"] = exclude_fields
535
+ merged = merge_extra_query(
536
+ kw, extra_query, allowlist=TRACKERS_EXTRA_KEYS
537
+ )
538
+ merged = _finalize_trackers_kwargs(merged)
539
+ resp = get_session().trackers.retrieve_list(
540
+ api_version=api_version,
541
+ **merged,
542
+ )
543
+ return {
544
+ "ok": True,
545
+ **paginated_summary(resp, limit=lim, offset=off),
546
+ }
547
+ except (requests.RequestException, ValueError) as e:
548
+ if isinstance(e, ValueError):
549
+ return {"ok": False, "error": "bad_request", "detail": str(e)}
550
+ return {"ok": False, **http_error_payload(e)}
551
+
552
+
553
+ def flaw_comments_list(
554
+ flaw_id: str,
555
+ *,
556
+ limit: int = DEFAULT_LIST_LIMIT,
557
+ offset: int = 0,
558
+ api_version: str | None = None,
559
+ ) -> dict[str, Any]:
560
+ lim = clamp_limit(limit)
561
+ off = clamp_offset(offset)
562
+ try:
563
+ resp = get_session().flaws.comments.retrieve_list(
564
+ flaw_id,
565
+ api_version=api_version,
566
+ limit=lim,
567
+ offset=off,
568
+ )
569
+ return {
570
+ "ok": True,
571
+ **paginated_summary(resp, limit=lim, offset=off),
572
+ }
573
+ except requests.RequestException as e:
574
+ return {"ok": False, **http_error_payload(e)}
575
+
576
+
577
+ def flaw_references_list(
578
+ flaw_id: str,
579
+ *,
580
+ limit: int = DEFAULT_LIST_LIMIT,
581
+ offset: int = 0,
582
+ api_version: str | None = None,
583
+ ) -> dict[str, Any]:
584
+ lim = clamp_limit(limit)
585
+ off = clamp_offset(offset)
586
+ try:
587
+ resp = get_session().flaws.references.retrieve_list(
588
+ flaw_id,
589
+ api_version=api_version,
590
+ limit=lim,
591
+ offset=off,
592
+ )
593
+ return {
594
+ "ok": True,
595
+ **paginated_summary(resp, limit=lim, offset=off),
596
+ }
597
+ except requests.RequestException as e:
598
+ return {"ok": False, **http_error_payload(e)}
599
+
600
+
601
+ def flaw_cvss_scores_list(
602
+ flaw_id: str,
603
+ *,
604
+ limit: int = DEFAULT_LIST_LIMIT,
605
+ offset: int = 0,
606
+ api_version: str | None = None,
607
+ ) -> dict[str, Any]:
608
+ lim = clamp_limit(limit)
609
+ off = clamp_offset(offset)
610
+ try:
611
+ resp = get_session().flaws.cvss_scores.retrieve_list(
612
+ flaw_id,
613
+ api_version=api_version,
614
+ limit=lim,
615
+ offset=off,
616
+ )
617
+ return {
618
+ "ok": True,
619
+ **paginated_summary(resp, limit=lim, offset=off),
620
+ }
621
+ except requests.RequestException as e:
622
+ return {"ok": False, **http_error_payload(e)}