cleanlib-mcp-server 0.2.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.
- cleanlib_mcp_server-0.2.0/LICENSE +15 -0
- cleanlib_mcp_server-0.2.0/PKG-INFO +109 -0
- cleanlib_mcp_server-0.2.0/README.md +74 -0
- cleanlib_mcp_server-0.2.0/pyproject.toml +60 -0
- cleanlib_mcp_server-0.2.0/setup.cfg +4 -0
- cleanlib_mcp_server-0.2.0/src/cleanlib_mcp/__init__.py +8 -0
- cleanlib_mcp_server-0.2.0/src/cleanlib_mcp/backend.py +221 -0
- cleanlib_mcp_server-0.2.0/src/cleanlib_mcp/server.py +128 -0
- cleanlib_mcp_server-0.2.0/src/cleanlib_mcp_server.egg-info/PKG-INFO +109 -0
- cleanlib_mcp_server-0.2.0/src/cleanlib_mcp_server.egg-info/SOURCES.txt +13 -0
- cleanlib_mcp_server-0.2.0/src/cleanlib_mcp_server.egg-info/dependency_links.txt +1 -0
- cleanlib_mcp_server-0.2.0/src/cleanlib_mcp_server.egg-info/entry_points.txt +2 -0
- cleanlib_mcp_server-0.2.0/src/cleanlib_mcp_server.egg-info/requires.txt +8 -0
- cleanlib_mcp_server-0.2.0/src/cleanlib_mcp_server.egg-info/top_level.txt +1 -0
- cleanlib_mcp_server-0.2.0/tests/test_backend.py +195 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
CleanStart Inc Proprietary License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 CleanStart Inc. All rights reserved.
|
|
4
|
+
|
|
5
|
+
This software is the proprietary property of CleanStart Inc. Use is permitted
|
|
6
|
+
only under the terms of a separate commercial license agreement with CleanStart
|
|
7
|
+
Inc. or its authorized distributors. Unauthorized use, modification, distribution,
|
|
8
|
+
or reverse engineering is prohibited.
|
|
9
|
+
|
|
10
|
+
For licensing inquiries: cto.office@cleanstart.com
|
|
11
|
+
|
|
12
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
13
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
|
14
|
+
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
15
|
+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY.
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cleanlib-mcp-server
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: CleanLibrary MCP server — exposes verdict-aware supply-chain risk assessment as Model Context Protocol tools for AI agent workflows
|
|
5
|
+
Author-email: CleanStart Inc <cto.office@cleanstart.com>
|
|
6
|
+
Maintainer-email: CleanStart Inc <cto.office@cleanstart.com>
|
|
7
|
+
License: CleanStart Inc Proprietary
|
|
8
|
+
Project-URL: Homepage, https://cleanlibrary.clnstrt.dev
|
|
9
|
+
Project-URL: Documentation, https://cleanlibrary.clnstrt.dev
|
|
10
|
+
Keywords: cleanlibrary,mcp,model-context-protocol,cleanstart,supply-chain-security,verdict,policy-evaluation
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Information Technology
|
|
14
|
+
Classifier: License :: Other/Proprietary License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Security
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Topic :: System :: Software Distribution
|
|
24
|
+
Requires-Python: >=3.11
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Requires-Dist: mcp>=1.0.0
|
|
28
|
+
Requires-Dist: httpx>=0.27.0
|
|
29
|
+
Requires-Dist: pydantic>=2.7.0
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
32
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
|
|
33
|
+
Requires-Dist: ruff>=0.6.0; extra == "dev"
|
|
34
|
+
Dynamic: license-file
|
|
35
|
+
|
|
36
|
+
# cleanlib-mcp-server
|
|
37
|
+
|
|
38
|
+
CleanLibrary MCP (Model Context Protocol) server — expose verdict-aware supply-chain risk assessment as MCP tools, so MCP-capable clients (Claude Code, Claude Desktop, Cursor, GitHub Copilot, and other agents) can fetch package verdicts directly inside the developer's workflow.
|
|
39
|
+
|
|
40
|
+
Ask your AI assistant *"is cors@2.8.4 safe to install?"* and it queries CleanLibrary for an `ALLOW` / `DENY` / `WARN` verdict with reasoning and confidence — without leaving the editor.
|
|
41
|
+
|
|
42
|
+
## Tools
|
|
43
|
+
|
|
44
|
+
| Tool | Description |
|
|
45
|
+
|---|---|
|
|
46
|
+
| `cleanlib_fetch_verdict(ecosystem, package, version)` | Fetch a verdict (`ALLOW` / `DENY` / `WARN`) with reasoning and confidence for a package version |
|
|
47
|
+
| `cleanlib_health_check()` | Report server status + whether a live CleanLibrary backend is configured |
|
|
48
|
+
|
|
49
|
+
## Install
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install cleanlib-mcp-server
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Run
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
cleanlib-mcp-server # stdio transport (per MCP spec)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Backend modes
|
|
62
|
+
|
|
63
|
+
- **Connected** — when `CLEANLIB_ENDPOINT` + `CLEANLIB_API_KEY` are set, the server queries your CleanLibrary deployment for live verdicts.
|
|
64
|
+
- **Local fixtures** — when no endpoint is configured (or the configured endpoint is unreachable), the server returns bundled demo fixtures so MCP clients always receive useful output.
|
|
65
|
+
|
|
66
|
+
## MCP client integration
|
|
67
|
+
|
|
68
|
+
The server speaks standard MCP over stdio, so it works with any MCP-capable client. Example configuration (Claude Desktop — `~/Library/Application Support/Claude/claude_desktop_config.json`; other clients use the same `mcpServers` shape):
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
{
|
|
72
|
+
"mcpServers": {
|
|
73
|
+
"cleanlibrary": {
|
|
74
|
+
"command": "cleanlib-mcp-server"
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
To connect a live CleanLibrary backend, add the endpoint + API key:
|
|
81
|
+
|
|
82
|
+
```json
|
|
83
|
+
{
|
|
84
|
+
"mcpServers": {
|
|
85
|
+
"cleanlibrary": {
|
|
86
|
+
"command": "cleanlib-mcp-server",
|
|
87
|
+
"env": {
|
|
88
|
+
"CLEANLIB_ENDPOINT": "https://cleanapp.clnstrt.dev",
|
|
89
|
+
"CLEANLIB_API_KEY": "clk_..."
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The same `command` + `env` pattern applies to Cursor, GitHub Copilot, and other MCP clients — consult your client's MCP server configuration docs for the exact file location.
|
|
97
|
+
|
|
98
|
+
## Development
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
python -m venv .venv && source .venv/bin/activate
|
|
102
|
+
pip install -e ".[dev]"
|
|
103
|
+
ruff check src tests
|
|
104
|
+
pytest -v
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## License
|
|
108
|
+
|
|
109
|
+
Proprietary. See [LICENSE](./LICENSE) for terms. © 2026 CleanStart Inc.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# cleanlib-mcp-server
|
|
2
|
+
|
|
3
|
+
CleanLibrary MCP (Model Context Protocol) server — expose verdict-aware supply-chain risk assessment as MCP tools, so MCP-capable clients (Claude Code, Claude Desktop, Cursor, GitHub Copilot, and other agents) can fetch package verdicts directly inside the developer's workflow.
|
|
4
|
+
|
|
5
|
+
Ask your AI assistant *"is cors@2.8.4 safe to install?"* and it queries CleanLibrary for an `ALLOW` / `DENY` / `WARN` verdict with reasoning and confidence — without leaving the editor.
|
|
6
|
+
|
|
7
|
+
## Tools
|
|
8
|
+
|
|
9
|
+
| Tool | Description |
|
|
10
|
+
|---|---|
|
|
11
|
+
| `cleanlib_fetch_verdict(ecosystem, package, version)` | Fetch a verdict (`ALLOW` / `DENY` / `WARN`) with reasoning and confidence for a package version |
|
|
12
|
+
| `cleanlib_health_check()` | Report server status + whether a live CleanLibrary backend is configured |
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install cleanlib-mcp-server
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Run
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
cleanlib-mcp-server # stdio transport (per MCP spec)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Backend modes
|
|
27
|
+
|
|
28
|
+
- **Connected** — when `CLEANLIB_ENDPOINT` + `CLEANLIB_API_KEY` are set, the server queries your CleanLibrary deployment for live verdicts.
|
|
29
|
+
- **Local fixtures** — when no endpoint is configured (or the configured endpoint is unreachable), the server returns bundled demo fixtures so MCP clients always receive useful output.
|
|
30
|
+
|
|
31
|
+
## MCP client integration
|
|
32
|
+
|
|
33
|
+
The server speaks standard MCP over stdio, so it works with any MCP-capable client. Example configuration (Claude Desktop — `~/Library/Application Support/Claude/claude_desktop_config.json`; other clients use the same `mcpServers` shape):
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
{
|
|
37
|
+
"mcpServers": {
|
|
38
|
+
"cleanlibrary": {
|
|
39
|
+
"command": "cleanlib-mcp-server"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
To connect a live CleanLibrary backend, add the endpoint + API key:
|
|
46
|
+
|
|
47
|
+
```json
|
|
48
|
+
{
|
|
49
|
+
"mcpServers": {
|
|
50
|
+
"cleanlibrary": {
|
|
51
|
+
"command": "cleanlib-mcp-server",
|
|
52
|
+
"env": {
|
|
53
|
+
"CLEANLIB_ENDPOINT": "https://cleanapp.clnstrt.dev",
|
|
54
|
+
"CLEANLIB_API_KEY": "clk_..."
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The same `command` + `env` pattern applies to Cursor, GitHub Copilot, and other MCP clients — consult your client's MCP server configuration docs for the exact file location.
|
|
62
|
+
|
|
63
|
+
## Development
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
python -m venv .venv && source .venv/bin/activate
|
|
67
|
+
pip install -e ".[dev]"
|
|
68
|
+
ruff check src tests
|
|
69
|
+
pytest -v
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## License
|
|
73
|
+
|
|
74
|
+
Proprietary. See [LICENSE](./LICENSE) for terms. © 2026 CleanStart Inc.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "cleanlib-mcp-server"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "CleanLibrary MCP server — exposes verdict-aware supply-chain risk assessment as Model Context Protocol tools for AI agent workflows"
|
|
9
|
+
readme = { file = "README.md", content-type = "text/markdown" }
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { text = "CleanStart Inc Proprietary" }
|
|
12
|
+
authors = [{ name = "CleanStart Inc", email = "cto.office@cleanstart.com" }]
|
|
13
|
+
maintainers = [{ name = "CleanStart Inc", email = "cto.office@cleanstart.com" }]
|
|
14
|
+
keywords = ["cleanlibrary", "mcp", "model-context-protocol", "cleanstart", "supply-chain-security", "verdict", "policy-evaluation"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Intended Audience :: Information Technology",
|
|
19
|
+
"License :: Other/Proprietary License",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
"Programming Language :: Python",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Topic :: Security",
|
|
27
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
28
|
+
"Topic :: System :: Software Distribution",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
dependencies = [
|
|
32
|
+
"mcp>=1.0.0",
|
|
33
|
+
"httpx>=0.27.0",
|
|
34
|
+
"pydantic>=2.7.0",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[project.urls]
|
|
38
|
+
Homepage = "https://cleanlibrary.clnstrt.dev"
|
|
39
|
+
Documentation = "https://cleanlibrary.clnstrt.dev"
|
|
40
|
+
|
|
41
|
+
[project.optional-dependencies]
|
|
42
|
+
dev = [
|
|
43
|
+
"pytest>=8.0.0",
|
|
44
|
+
"pytest-asyncio>=0.23.0",
|
|
45
|
+
"ruff>=0.6.0",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
[project.scripts]
|
|
49
|
+
cleanlib-mcp-server = "cleanlib_mcp.server:main"
|
|
50
|
+
|
|
51
|
+
[tool.setuptools.packages.find]
|
|
52
|
+
where = ["src"]
|
|
53
|
+
|
|
54
|
+
[tool.pytest.ini_options]
|
|
55
|
+
asyncio_mode = "auto"
|
|
56
|
+
testpaths = ["tests"]
|
|
57
|
+
|
|
58
|
+
[tool.ruff]
|
|
59
|
+
line-length = 100
|
|
60
|
+
target-version = "py311"
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""CleanLibrary MCP server.
|
|
2
|
+
|
|
3
|
+
Exposes CleanLibrary verdict-aware supply-chain risk assessment as MCP tools
|
|
4
|
+
so MCP-capable clients (Claude Code, Claude Desktop, Cursor, GitHub Copilot,
|
|
5
|
+
and other agents) can fetch package verdicts directly.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.2.0"
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Verdict backend.
|
|
2
|
+
|
|
3
|
+
When CLEANLIB_ENDPOINT + CLEANLIB_API_KEY are configured, fetches verdicts
|
|
4
|
+
from the CleanLibrary App `GET /v1/customer/verdicts/{eco}/{pkg}/{ver}`.
|
|
5
|
+
When unconfigured (or the endpoint is unreachable), returns bundled demo
|
|
6
|
+
fixtures so MCP clients always receive useful output.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import secrets
|
|
13
|
+
from dataclasses import asdict, dataclass
|
|
14
|
+
from typing import Literal
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
Decision = Literal["ALLOW", "DENY", "WARN"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Verdict:
|
|
23
|
+
"""Mirrors the CleanLibrary App GET /v1/customer/verdicts response shape."""
|
|
24
|
+
|
|
25
|
+
verdict_id: str
|
|
26
|
+
ecosystem: str
|
|
27
|
+
package: str
|
|
28
|
+
version: str
|
|
29
|
+
decision: Decision
|
|
30
|
+
reasoning: str | None = None
|
|
31
|
+
confidence: float | None = None
|
|
32
|
+
source: str | None = None
|
|
33
|
+
|
|
34
|
+
def to_dict(self) -> dict:
|
|
35
|
+
return {k: v for k, v in asdict(self).items() if v is not None}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# Bundled demo fixtures (cors-2.8.4 DENY / cors-2.8.5 ALLOW / helmet ALLOW /
|
|
39
|
+
# dotenv ALLOW / oracledb WARN) used when no live backend is configured.
|
|
40
|
+
_MOCK_FIXTURES: dict[str, dict] = {
|
|
41
|
+
"npm/cors/2.8.4": {
|
|
42
|
+
"decision": "DENY",
|
|
43
|
+
"reasoning": "Policy rule: cors-2.8.4 has known prototype pollution; upgrade to 2.8.5",
|
|
44
|
+
"confidence": 0.98,
|
|
45
|
+
"source": "VECTOR_VERDICT",
|
|
46
|
+
},
|
|
47
|
+
"npm/cors/2.8.5": {
|
|
48
|
+
"decision": "ALLOW",
|
|
49
|
+
"reasoning": "No findings; matches policy allowlist",
|
|
50
|
+
"confidence": 0.95,
|
|
51
|
+
"source": "ALLOWED_NO_FINDINGS",
|
|
52
|
+
},
|
|
53
|
+
"npm/helmet/8.0.0": {
|
|
54
|
+
"decision": "ALLOW",
|
|
55
|
+
"reasoning": "No findings; standard security middleware",
|
|
56
|
+
"confidence": 0.97,
|
|
57
|
+
"source": "ALLOWED_NO_FINDINGS",
|
|
58
|
+
},
|
|
59
|
+
"npm/dotenv/16.4.5": {
|
|
60
|
+
"decision": "ALLOW",
|
|
61
|
+
"reasoning": "No findings; widely-used config loader",
|
|
62
|
+
"confidence": 0.94,
|
|
63
|
+
"source": "ALLOWED_NO_FINDINGS",
|
|
64
|
+
},
|
|
65
|
+
"npm/oracledb/6.5.1": {
|
|
66
|
+
"decision": "WARN",
|
|
67
|
+
"reasoning": "Vendor-binary present; review supply-chain provenance before deploy",
|
|
68
|
+
"confidence": 0.70,
|
|
69
|
+
"source": "INSUFFICIENT_DATA",
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def fetch_verdict(ecosystem: str, package: str, version: str) -> Verdict:
|
|
75
|
+
"""Fetch verdict for a package version.
|
|
76
|
+
|
|
77
|
+
Fixture-mode (default): returns a bundled demo fixture, or default-ALLOW
|
|
78
|
+
for unknown packages.
|
|
79
|
+
|
|
80
|
+
Live-mode (when CLEANLIB_ENDPOINT + CLEANLIB_API_KEY env set): queries the
|
|
81
|
+
CleanLibrary App customer-verdict endpoint, falling back to a fixture if
|
|
82
|
+
the endpoint is unreachable so the client always receives output.
|
|
83
|
+
"""
|
|
84
|
+
endpoint = os.getenv("CLEANLIB_ENDPOINT", "").strip()
|
|
85
|
+
api_key = os.getenv("CLEANLIB_API_KEY", "").strip()
|
|
86
|
+
|
|
87
|
+
if endpoint and api_key:
|
|
88
|
+
try:
|
|
89
|
+
return await _live_fetch(endpoint, api_key, ecosystem, package, version)
|
|
90
|
+
except LiveBackendNotDeployed:
|
|
91
|
+
# Configured endpoint unreachable — fall back to a fixture so the
|
|
92
|
+
# client still receives useful output. The fallback verdict carries
|
|
93
|
+
# a reasoning string surfacing that the live backend was unavailable.
|
|
94
|
+
v = _mock_fetch(ecosystem, package, version)
|
|
95
|
+
v.reasoning = (
|
|
96
|
+
f"[live-mode fallback: CleanLibrary backend at {endpoint} unavailable] "
|
|
97
|
+
+ (v.reasoning or "")
|
|
98
|
+
)
|
|
99
|
+
return v
|
|
100
|
+
return _mock_fetch(ecosystem, package, version)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _mock_fetch(ecosystem: str, package: str, version: str) -> Verdict:
|
|
104
|
+
key = f"{ecosystem}/{package}/{version}"
|
|
105
|
+
verdict_id = f"mock_{secrets.token_hex(4)}"
|
|
106
|
+
fixture = _MOCK_FIXTURES.get(key)
|
|
107
|
+
if fixture is not None:
|
|
108
|
+
return Verdict(
|
|
109
|
+
verdict_id=verdict_id,
|
|
110
|
+
ecosystem=ecosystem,
|
|
111
|
+
package=package,
|
|
112
|
+
version=version,
|
|
113
|
+
**fixture,
|
|
114
|
+
)
|
|
115
|
+
return Verdict(
|
|
116
|
+
verdict_id=verdict_id,
|
|
117
|
+
ecosystem=ecosystem,
|
|
118
|
+
package=package,
|
|
119
|
+
version=version,
|
|
120
|
+
decision="ALLOW",
|
|
121
|
+
reasoning="Default-ALLOW (no fixture matched; live App returned no verdict for this package version)",
|
|
122
|
+
confidence=0.5,
|
|
123
|
+
source="INSUFFICIENT_DATA",
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class LiveBackendNotDeployed(Exception):
|
|
128
|
+
"""Raised when CLEANLIB_ENDPOINT is set but the verdict endpoint is
|
|
129
|
+
unreachable (empty-body 404). Distinguished from "package has no verdict"
|
|
130
|
+
(a structured-JSON 404) by response content-type. The tool layer treats
|
|
131
|
+
this as a degraded-mode signal and falls back to a fixture so clients
|
|
132
|
+
receive useful output even when the live backend is unavailable."""
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
async def _live_fetch(
|
|
136
|
+
endpoint: str, api_key: str, ecosystem: str, package: str, version: str
|
|
137
|
+
) -> Verdict:
|
|
138
|
+
"""Live `GET /v1/customer/verdicts/{eco}/{pkg}/{ver}` call against the
|
|
139
|
+
configured CleanLibrary App.
|
|
140
|
+
|
|
141
|
+
A structured-JSON 404 means "verdict not found" for that package version;
|
|
142
|
+
an empty-body 404 means the endpoint is unreachable. The wrapper
|
|
143
|
+
distinguishes the two by response content-type and falls back to a
|
|
144
|
+
fixture in the unreachable case.
|
|
145
|
+
"""
|
|
146
|
+
url = f"{endpoint.rstrip('/')}/v1/customer/verdicts/{ecosystem}/{package}/{version}"
|
|
147
|
+
headers = {
|
|
148
|
+
"authorization": f"Bearer {api_key}",
|
|
149
|
+
"accept": "application/json",
|
|
150
|
+
"user-agent": "cleanlib-mcp-server/0.2.0",
|
|
151
|
+
}
|
|
152
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
153
|
+
try:
|
|
154
|
+
resp = await client.get(url, headers=headers)
|
|
155
|
+
except httpx.HTTPError as e:
|
|
156
|
+
raise RuntimeError(f"live App transport failure: {e}") from e
|
|
157
|
+
|
|
158
|
+
if resp.status_code == 200:
|
|
159
|
+
data = resp.json()
|
|
160
|
+
return Verdict(
|
|
161
|
+
verdict_id=data.get("verdict_id", ""),
|
|
162
|
+
ecosystem=data.get("ecosystem", ecosystem),
|
|
163
|
+
package=data.get("package", package),
|
|
164
|
+
version=data.get("version", version),
|
|
165
|
+
decision=data.get("decision", "ALLOW"),
|
|
166
|
+
reasoning=data.get("reasoning"),
|
|
167
|
+
confidence=data.get("confidence"),
|
|
168
|
+
source=data.get("source"),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
if resp.status_code == 404:
|
|
172
|
+
# Two cases: (a) endpoint reached but no verdict for this package
|
|
173
|
+
# (structured-JSON body); (b) endpoint unreachable (empty body or
|
|
174
|
+
# text/html). Distinguish via content-type.
|
|
175
|
+
content_type = resp.headers.get("content-type", "")
|
|
176
|
+
if "json" in content_type and resp.content:
|
|
177
|
+
# "Verdict not found" — return an INSUFFICIENT_DATA verdict.
|
|
178
|
+
return Verdict(
|
|
179
|
+
verdict_id=f"live_404_{secrets.token_hex(4)}",
|
|
180
|
+
ecosystem=ecosystem,
|
|
181
|
+
package=package,
|
|
182
|
+
version=version,
|
|
183
|
+
decision="ALLOW",
|
|
184
|
+
reasoning=f"No verdict on file for {ecosystem}/{package}@{version}",
|
|
185
|
+
confidence=None,
|
|
186
|
+
source="INSUFFICIENT_DATA",
|
|
187
|
+
)
|
|
188
|
+
raise LiveBackendNotDeployed(
|
|
189
|
+
f"CleanLibrary backend at {endpoint} did not return a verdict for "
|
|
190
|
+
f"/v1/customer/verdicts/{ecosystem}/{package}/{version}; "
|
|
191
|
+
f"returning fixture fallback."
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
if resp.status_code == 401 or resp.status_code == 403:
|
|
195
|
+
raise PermissionError(
|
|
196
|
+
f"App rejected Bearer (status={resp.status_code}); check CLEANLIB_API_KEY "
|
|
197
|
+
f"is valid for {endpoint}"
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
raise RuntimeError(f"App returned unexpected status {resp.status_code}: {resp.text[:200]}")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
async def _validate_live_endpoint_reachable(endpoint: str) -> bool:
|
|
204
|
+
"""Reachability probe via /health (public; no auth required). Used by
|
|
205
|
+
`cleanlib_health_check` MCP tool to verify MCP→App transport works
|
|
206
|
+
end-to-end even when the verdict endpoint isn't yet deployed."""
|
|
207
|
+
try:
|
|
208
|
+
async with httpx.AsyncClient(timeout=5.0) as client:
|
|
209
|
+
resp = await client.get(f"{endpoint.rstrip('/')}/health")
|
|
210
|
+
return resp.status_code == 200
|
|
211
|
+
except Exception:
|
|
212
|
+
return False
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
async def live_health(endpoint: str) -> dict:
|
|
216
|
+
"""Fetch the App's /health response (JSON) — used by the
|
|
217
|
+
`cleanlib_health_check` MCP tool. Public endpoint; no auth needed."""
|
|
218
|
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
219
|
+
resp = await client.get(f"{endpoint.rstrip('/')}/health")
|
|
220
|
+
resp.raise_for_status()
|
|
221
|
+
return resp.json()
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""MCP server entry-point — registers CleanLibrary tools and serves over
|
|
2
|
+
stdio transport. Compatible with any MCP-capable client (Claude Code,
|
|
3
|
+
Claude Desktop, Cursor, GitHub Copilot, and other agents).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import logging
|
|
10
|
+
|
|
11
|
+
from mcp.server import Server
|
|
12
|
+
from mcp.server.stdio import stdio_server
|
|
13
|
+
from mcp.types import TextContent, Tool
|
|
14
|
+
|
|
15
|
+
from .backend import fetch_verdict, live_health
|
|
16
|
+
|
|
17
|
+
log = logging.getLogger("cleanlib-mcp-server")
|
|
18
|
+
|
|
19
|
+
server: Server = Server("cleanlib-mcp-server")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@server.list_tools()
|
|
23
|
+
async def list_tools() -> list[Tool]:
|
|
24
|
+
return [
|
|
25
|
+
Tool(
|
|
26
|
+
name="cleanlib_fetch_verdict",
|
|
27
|
+
description=(
|
|
28
|
+
"Fetch the CleanLibrary verdict for a package version. Returns the "
|
|
29
|
+
"decision (ALLOW / DENY / WARN), reasoning, and confidence. Queries a "
|
|
30
|
+
"live CleanLibrary backend when CLEANLIB_ENDPOINT + CLEANLIB_API_KEY "
|
|
31
|
+
"are configured; otherwise returns bundled demo fixtures so the tool "
|
|
32
|
+
"always responds."
|
|
33
|
+
),
|
|
34
|
+
inputSchema={
|
|
35
|
+
"type": "object",
|
|
36
|
+
"properties": {
|
|
37
|
+
"ecosystem": {
|
|
38
|
+
"type": "string",
|
|
39
|
+
"enum": ["npm", "pypi", "go", "maven", "crates", "nuget", "rubygems"],
|
|
40
|
+
"description": "Package ecosystem",
|
|
41
|
+
},
|
|
42
|
+
"package": {
|
|
43
|
+
"type": "string",
|
|
44
|
+
"description": "Package name (e.g. lodash, requests, github.com/spf13/cobra)",
|
|
45
|
+
},
|
|
46
|
+
"version": {
|
|
47
|
+
"type": "string",
|
|
48
|
+
"description": "Semantic version (e.g. 4.17.21)",
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
"required": ["ecosystem", "package", "version"],
|
|
52
|
+
},
|
|
53
|
+
),
|
|
54
|
+
Tool(
|
|
55
|
+
name="cleanlib_health_check",
|
|
56
|
+
description=(
|
|
57
|
+
"Verify MCP→App transport reachability via the public /health "
|
|
58
|
+
"endpoint. Returns the App's health JSON ({status, service, version, "
|
|
59
|
+
"ecosystems_mounted}). Requires CLEANLIB_ENDPOINT env var. Public "
|
|
60
|
+
"endpoint; no API key needed. Use this to confirm MCP server can "
|
|
61
|
+
"reach the deployed App before invoking verdict fetches."
|
|
62
|
+
),
|
|
63
|
+
inputSchema={
|
|
64
|
+
"type": "object",
|
|
65
|
+
"properties": {},
|
|
66
|
+
"required": [],
|
|
67
|
+
},
|
|
68
|
+
),
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@server.call_tool()
|
|
73
|
+
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|
74
|
+
if name == "cleanlib_fetch_verdict":
|
|
75
|
+
ecosystem = arguments.get("ecosystem", "")
|
|
76
|
+
package = arguments.get("package", "")
|
|
77
|
+
version = arguments.get("version", "")
|
|
78
|
+
if not (ecosystem and package and version):
|
|
79
|
+
return [TextContent(type="text", text="Error: ecosystem, package, and version are all required.")]
|
|
80
|
+
try:
|
|
81
|
+
verdict = await fetch_verdict(ecosystem, package, version)
|
|
82
|
+
except PermissionError as e:
|
|
83
|
+
return [TextContent(type="text", text=f"Auth error: {e}")]
|
|
84
|
+
except Exception as e:
|
|
85
|
+
return [TextContent(type="text", text=f"Verdict fetch failed: {e}")]
|
|
86
|
+
# Render as concise human-readable summary + JSON detail for agents.
|
|
87
|
+
icon = "✓" if verdict.decision == "ALLOW" else "✗" if verdict.decision == "DENY" else "⚠"
|
|
88
|
+
confidence_str = f"{verdict.confidence:.0%}" if verdict.confidence is not None else "n/a"
|
|
89
|
+
summary = (
|
|
90
|
+
f"{icon} {verdict.ecosystem}/{verdict.package}@{verdict.version} → "
|
|
91
|
+
f"{verdict.decision} (confidence {confidence_str})\n\n"
|
|
92
|
+
f"Reasoning: {verdict.reasoning or '(none)'}\n\n"
|
|
93
|
+
f"verdict_id: {verdict.verdict_id}"
|
|
94
|
+
)
|
|
95
|
+
return [TextContent(type="text", text=summary)]
|
|
96
|
+
|
|
97
|
+
if name == "cleanlib_health_check":
|
|
98
|
+
import os
|
|
99
|
+
endpoint = os.getenv("CLEANLIB_ENDPOINT", "").strip()
|
|
100
|
+
if not endpoint:
|
|
101
|
+
return [TextContent(type="text", text="CLEANLIB_ENDPOINT env var not set; cannot probe.")]
|
|
102
|
+
try:
|
|
103
|
+
data = await live_health(endpoint)
|
|
104
|
+
except Exception as e:
|
|
105
|
+
return [TextContent(type="text", text=f"Health probe failed against {endpoint}: {e}")]
|
|
106
|
+
return [TextContent(type="text", text=(
|
|
107
|
+
f"✓ CleanLibrary App reachable at {endpoint}\n\n"
|
|
108
|
+
f"status: {data.get('status')}\n"
|
|
109
|
+
f"service: {data.get('service')}\n"
|
|
110
|
+
f"version: {data.get('version')}\n"
|
|
111
|
+
f"ecosystems_mounted: {', '.join(data.get('ecosystems_mounted', []))}"
|
|
112
|
+
))]
|
|
113
|
+
|
|
114
|
+
return [TextContent(type="text", text=f"Unknown tool: {name}")]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
async def serve() -> None:
|
|
118
|
+
async with stdio_server() as (read_stream, write_stream):
|
|
119
|
+
await server.run(read_stream, write_stream, server.create_initialization_options())
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def main() -> None:
|
|
123
|
+
logging.basicConfig(level=logging.INFO)
|
|
124
|
+
asyncio.run(serve())
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
if __name__ == "__main__":
|
|
128
|
+
main()
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cleanlib-mcp-server
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: CleanLibrary MCP server — exposes verdict-aware supply-chain risk assessment as Model Context Protocol tools for AI agent workflows
|
|
5
|
+
Author-email: CleanStart Inc <cto.office@cleanstart.com>
|
|
6
|
+
Maintainer-email: CleanStart Inc <cto.office@cleanstart.com>
|
|
7
|
+
License: CleanStart Inc Proprietary
|
|
8
|
+
Project-URL: Homepage, https://cleanlibrary.clnstrt.dev
|
|
9
|
+
Project-URL: Documentation, https://cleanlibrary.clnstrt.dev
|
|
10
|
+
Keywords: cleanlibrary,mcp,model-context-protocol,cleanstart,supply-chain-security,verdict,policy-evaluation
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Information Technology
|
|
14
|
+
Classifier: License :: Other/Proprietary License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Security
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Topic :: System :: Software Distribution
|
|
24
|
+
Requires-Python: >=3.11
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Requires-Dist: mcp>=1.0.0
|
|
28
|
+
Requires-Dist: httpx>=0.27.0
|
|
29
|
+
Requires-Dist: pydantic>=2.7.0
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
32
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
|
|
33
|
+
Requires-Dist: ruff>=0.6.0; extra == "dev"
|
|
34
|
+
Dynamic: license-file
|
|
35
|
+
|
|
36
|
+
# cleanlib-mcp-server
|
|
37
|
+
|
|
38
|
+
CleanLibrary MCP (Model Context Protocol) server — expose verdict-aware supply-chain risk assessment as MCP tools, so MCP-capable clients (Claude Code, Claude Desktop, Cursor, GitHub Copilot, and other agents) can fetch package verdicts directly inside the developer's workflow.
|
|
39
|
+
|
|
40
|
+
Ask your AI assistant *"is cors@2.8.4 safe to install?"* and it queries CleanLibrary for an `ALLOW` / `DENY` / `WARN` verdict with reasoning and confidence — without leaving the editor.
|
|
41
|
+
|
|
42
|
+
## Tools
|
|
43
|
+
|
|
44
|
+
| Tool | Description |
|
|
45
|
+
|---|---|
|
|
46
|
+
| `cleanlib_fetch_verdict(ecosystem, package, version)` | Fetch a verdict (`ALLOW` / `DENY` / `WARN`) with reasoning and confidence for a package version |
|
|
47
|
+
| `cleanlib_health_check()` | Report server status + whether a live CleanLibrary backend is configured |
|
|
48
|
+
|
|
49
|
+
## Install
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install cleanlib-mcp-server
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Run
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
cleanlib-mcp-server # stdio transport (per MCP spec)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Backend modes
|
|
62
|
+
|
|
63
|
+
- **Connected** — when `CLEANLIB_ENDPOINT` + `CLEANLIB_API_KEY` are set, the server queries your CleanLibrary deployment for live verdicts.
|
|
64
|
+
- **Local fixtures** — when no endpoint is configured (or the configured endpoint is unreachable), the server returns bundled demo fixtures so MCP clients always receive useful output.
|
|
65
|
+
|
|
66
|
+
## MCP client integration
|
|
67
|
+
|
|
68
|
+
The server speaks standard MCP over stdio, so it works with any MCP-capable client. Example configuration (Claude Desktop — `~/Library/Application Support/Claude/claude_desktop_config.json`; other clients use the same `mcpServers` shape):
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
{
|
|
72
|
+
"mcpServers": {
|
|
73
|
+
"cleanlibrary": {
|
|
74
|
+
"command": "cleanlib-mcp-server"
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
To connect a live CleanLibrary backend, add the endpoint + API key:
|
|
81
|
+
|
|
82
|
+
```json
|
|
83
|
+
{
|
|
84
|
+
"mcpServers": {
|
|
85
|
+
"cleanlibrary": {
|
|
86
|
+
"command": "cleanlib-mcp-server",
|
|
87
|
+
"env": {
|
|
88
|
+
"CLEANLIB_ENDPOINT": "https://cleanapp.clnstrt.dev",
|
|
89
|
+
"CLEANLIB_API_KEY": "clk_..."
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The same `command` + `env` pattern applies to Cursor, GitHub Copilot, and other MCP clients — consult your client's MCP server configuration docs for the exact file location.
|
|
97
|
+
|
|
98
|
+
## Development
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
python -m venv .venv && source .venv/bin/activate
|
|
102
|
+
pip install -e ".[dev]"
|
|
103
|
+
ruff check src tests
|
|
104
|
+
pytest -v
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## License
|
|
108
|
+
|
|
109
|
+
Proprietary. See [LICENSE](./LICENSE) for terms. © 2026 CleanStart Inc.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/cleanlib_mcp/__init__.py
|
|
5
|
+
src/cleanlib_mcp/backend.py
|
|
6
|
+
src/cleanlib_mcp/server.py
|
|
7
|
+
src/cleanlib_mcp_server.egg-info/PKG-INFO
|
|
8
|
+
src/cleanlib_mcp_server.egg-info/SOURCES.txt
|
|
9
|
+
src/cleanlib_mcp_server.egg-info/dependency_links.txt
|
|
10
|
+
src/cleanlib_mcp_server.egg-info/entry_points.txt
|
|
11
|
+
src/cleanlib_mcp_server.egg-info/requires.txt
|
|
12
|
+
src/cleanlib_mcp_server.egg-info/top_level.txt
|
|
13
|
+
tests/test_backend.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cleanlib_mcp
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Substrate-mode backend tests — mock backend behavior with cycle-4 §D.7
|
|
2
|
+
demo fixtures."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from cleanlib_mcp.backend import fetch_verdict
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture(autouse=True)
|
|
14
|
+
def _clear_live_env(monkeypatch):
|
|
15
|
+
"""Force mock-mode by clearing live-backend env vars per test."""
|
|
16
|
+
monkeypatch.delenv("CLEANLIB_ENDPOINT", raising=False)
|
|
17
|
+
monkeypatch.delenv("CLEANLIB_API_KEY", raising=False)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def test_cors_2_8_4_returns_deny():
|
|
21
|
+
v = await fetch_verdict("npm", "cors", "2.8.4")
|
|
22
|
+
assert v.decision == "DENY"
|
|
23
|
+
assert v.reasoning and ("prototype pollution" in v.reasoning or "2.8.5" in v.reasoning)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def test_cors_2_8_5_returns_allow():
|
|
27
|
+
v = await fetch_verdict("npm", "cors", "2.8.5")
|
|
28
|
+
assert v.decision == "ALLOW"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def test_helmet_returns_allow():
|
|
32
|
+
v = await fetch_verdict("npm", "helmet", "8.0.0")
|
|
33
|
+
assert v.decision == "ALLOW"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def test_dotenv_returns_allow():
|
|
37
|
+
v = await fetch_verdict("npm", "dotenv", "16.4.5")
|
|
38
|
+
assert v.decision == "ALLOW"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def test_oracledb_returns_warn():
|
|
42
|
+
v = await fetch_verdict("npm", "oracledb", "6.5.1")
|
|
43
|
+
assert v.decision == "WARN"
|
|
44
|
+
assert v.reasoning and ("vendor-binary" in v.reasoning.lower() or "supply-chain" in v.reasoning.lower())
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def test_unknown_pkg_default_allow():
|
|
48
|
+
v = await fetch_verdict("npm", "some-unknown-pkg", "1.0.0")
|
|
49
|
+
assert v.decision == "ALLOW"
|
|
50
|
+
assert v.reasoning and "default-allow" in v.reasoning.lower()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def test_verdict_id_unique_per_call():
|
|
54
|
+
a = await fetch_verdict("npm", "lodash", "4.17.21")
|
|
55
|
+
b = await fetch_verdict("npm", "lodash", "4.17.21")
|
|
56
|
+
assert a.verdict_id != b.verdict_id
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def test_verdict_carries_input_eco_pkg_ver():
|
|
60
|
+
v = await fetch_verdict("pypi", "requests", "2.32.0")
|
|
61
|
+
assert v.ecosystem == "pypi"
|
|
62
|
+
assert v.package == "requests"
|
|
63
|
+
assert v.version == "2.32.0"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def test_verdict_to_dict_drops_none_values():
|
|
67
|
+
v = await fetch_verdict("npm", "helmet", "8.0.0")
|
|
68
|
+
d = v.to_dict()
|
|
69
|
+
# None values should be dropped from serialization
|
|
70
|
+
assert all(value is not None for value in d.values())
|
|
71
|
+
assert d["decision"] == "ALLOW"
|
|
72
|
+
assert d["package"] == "helmet"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _mock_transport(handler):
|
|
76
|
+
"""Helper: build an httpx.MockTransport from a request-handler callable."""
|
|
77
|
+
import httpx
|
|
78
|
+
return httpx.MockTransport(handler)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
async def test_live_fetch_200_returns_canonical_verdict(monkeypatch):
|
|
82
|
+
"""When App's Rev 4 §9.3 endpoint returns 200 + valid JSON, MCP parses
|
|
83
|
+
it into the canonical Verdict dataclass."""
|
|
84
|
+
import httpx
|
|
85
|
+
from cleanlib_mcp import backend as b
|
|
86
|
+
|
|
87
|
+
def handler(request):
|
|
88
|
+
assert request.url.path == "/v1/customer/verdicts/npm/lodash/4.17.21"
|
|
89
|
+
assert request.headers["authorization"] == "Bearer test-key"
|
|
90
|
+
return httpx.Response(200, json={
|
|
91
|
+
"verdict_id": "vrd_test123",
|
|
92
|
+
"ecosystem": "npm",
|
|
93
|
+
"package": "lodash",
|
|
94
|
+
"version": "4.17.21",
|
|
95
|
+
"decision": "ALLOW",
|
|
96
|
+
"reasoning": "no findings",
|
|
97
|
+
"confidence": 0.95,
|
|
98
|
+
"source": "ALLOWED_NO_FINDINGS",
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
_orig = httpx.AsyncClient
|
|
102
|
+
monkeypatch.setattr(b.httpx, "AsyncClient", lambda **kw: _orig(transport=_mock_transport(handler), **{k: v for k, v in kw.items() if k != "transport"}))
|
|
103
|
+
v = await b._live_fetch("https://app.test", "test-key", "npm", "lodash", "4.17.21")
|
|
104
|
+
assert v.verdict_id == "vrd_test123"
|
|
105
|
+
assert v.decision == "ALLOW"
|
|
106
|
+
assert v.confidence == 0.95
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
async def test_live_fetch_404_no_json_raises_live_backend_not_deployed(monkeypatch):
|
|
110
|
+
"""When App's endpoint returns 404 with empty/non-JSON body, MCP raises
|
|
111
|
+
LiveBackendNotDeployed so the wrapper can fall back to mock."""
|
|
112
|
+
import httpx
|
|
113
|
+
from cleanlib_mcp import backend as b
|
|
114
|
+
|
|
115
|
+
def handler(request):
|
|
116
|
+
return httpx.Response(404, content=b"<html>404 Not Found</html>", headers={"content-type": "text/html"})
|
|
117
|
+
|
|
118
|
+
_orig = httpx.AsyncClient
|
|
119
|
+
monkeypatch.setattr(b.httpx, "AsyncClient", lambda **kw: _orig(transport=_mock_transport(handler), **{k: v for k, v in kw.items() if k != "transport"}))
|
|
120
|
+
with pytest.raises(b.LiveBackendNotDeployed) as exc:
|
|
121
|
+
await b._live_fetch("https://app.test", "test-key", "npm", "lodash", "4.17.21")
|
|
122
|
+
assert "did not return a verdict" in str(exc.value)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def test_live_fetch_404_json_returns_no_verdict_substrate(monkeypatch):
|
|
126
|
+
"""When the endpoint returns 404 with a JSON body (reached, but no verdict
|
|
127
|
+
for this package), the backend returns a Verdict with INSUFFICIENT_DATA
|
|
128
|
+
source."""
|
|
129
|
+
import httpx
|
|
130
|
+
from cleanlib_mcp import backend as b
|
|
131
|
+
|
|
132
|
+
def handler(request):
|
|
133
|
+
return httpx.Response(404, json={"error": "verdict not found"}, headers={"content-type": "application/json"})
|
|
134
|
+
|
|
135
|
+
_orig = httpx.AsyncClient
|
|
136
|
+
monkeypatch.setattr(b.httpx, "AsyncClient", lambda **kw: _orig(transport=_mock_transport(handler), **{k: v for k, v in kw.items() if k != "transport"}))
|
|
137
|
+
v = await b._live_fetch("https://app.test", "test-key", "npm", "missing", "1.0.0")
|
|
138
|
+
assert v.decision == "ALLOW"
|
|
139
|
+
assert v.source == "INSUFFICIENT_DATA"
|
|
140
|
+
assert "No verdict on file" in (v.reasoning or "")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
async def test_live_fetch_401_raises_permission_error(monkeypatch):
|
|
144
|
+
"""Bad bearer → 401 → PermissionError."""
|
|
145
|
+
import httpx
|
|
146
|
+
from cleanlib_mcp import backend as b
|
|
147
|
+
|
|
148
|
+
def handler(request):
|
|
149
|
+
return httpx.Response(401, content=b"unauthorized")
|
|
150
|
+
|
|
151
|
+
_orig = httpx.AsyncClient
|
|
152
|
+
monkeypatch.setattr(b.httpx, "AsyncClient", lambda **kw: _orig(transport=_mock_transport(handler), **{k: v for k, v in kw.items() if k != "transport"}))
|
|
153
|
+
with pytest.raises(PermissionError) as exc:
|
|
154
|
+
await b._live_fetch("https://app.test", "bad-key", "npm", "lodash", "4.17.21")
|
|
155
|
+
assert "401" in str(exc.value)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
async def test_fetch_verdict_falls_back_to_mock_when_endpoint_not_deployed(monkeypatch):
|
|
159
|
+
"""End-to-end: live env set, App returns 404-not-deployed, fetch_verdict
|
|
160
|
+
falls back to mock fixture + annotates reasoning."""
|
|
161
|
+
import httpx
|
|
162
|
+
from cleanlib_mcp import backend as b
|
|
163
|
+
|
|
164
|
+
monkeypatch.setenv("CLEANLIB_ENDPOINT", "https://app.test")
|
|
165
|
+
monkeypatch.setenv("CLEANLIB_API_KEY", "test-key")
|
|
166
|
+
|
|
167
|
+
def handler(request):
|
|
168
|
+
return httpx.Response(404, content=b"<html>404</html>", headers={"content-type": "text/html"})
|
|
169
|
+
|
|
170
|
+
_orig = httpx.AsyncClient
|
|
171
|
+
monkeypatch.setattr(b.httpx, "AsyncClient", lambda **kw: _orig(transport=_mock_transport(handler), **{k: v for k, v in kw.items() if k != "transport"}))
|
|
172
|
+
v = await b.fetch_verdict("npm", "cors", "2.8.4")
|
|
173
|
+
assert v.decision == "DENY" # cors-2.8.4 fixture is DENY
|
|
174
|
+
assert "live-mode fallback" in (v.reasoning or "")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
async def test_live_health_returns_app_health_json(monkeypatch):
|
|
178
|
+
"""live_health probes /health and returns the JSON body."""
|
|
179
|
+
import httpx
|
|
180
|
+
from cleanlib_mcp import backend as b
|
|
181
|
+
|
|
182
|
+
def handler(request):
|
|
183
|
+
assert request.url.path == "/health"
|
|
184
|
+
return httpx.Response(200, json={
|
|
185
|
+
"status": "ok",
|
|
186
|
+
"service": "cleanlib-app",
|
|
187
|
+
"version": "0.1.0",
|
|
188
|
+
"ecosystems_mounted": ["npm", "pypi", "go", "maven", "crates", "nuget", "rubygems"],
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
_orig = httpx.AsyncClient
|
|
192
|
+
monkeypatch.setattr(b.httpx, "AsyncClient", lambda **kw: _orig(transport=_mock_transport(handler), **{k: v for k, v in kw.items() if k != "transport"}))
|
|
193
|
+
data = await b.live_health("https://app.test")
|
|
194
|
+
assert data["status"] == "ok"
|
|
195
|
+
assert "npm" in data["ecosystems_mounted"]
|