mcp4api 0.1.0__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.
- mcp4api/__init__.py +1 -0
- mcp4api/__main__.py +5 -0
- mcp4api/cassettes.py +96 -0
- mcp4api/cli.py +665 -0
- mcp4api/curate/__init__.py +3 -0
- mcp4api/curate/agent.py +850 -0
- mcp4api/curate/criteria.md +21 -0
- mcp4api/curate/draft.py +606 -0
- mcp4api/curate/models.py +614 -0
- mcp4api/curate/rules.py +1059 -0
- mcp4api/emit/__init__.py +205 -0
- mcp4api/emit/catalog.py +717 -0
- mcp4api/emit/render.py +917 -0
- mcp4api/emit/templates/README.md.j2 +58 -0
- mcp4api/emit/templates/client.py.j2 +305 -0
- mcp4api/emit/templates/conftest.py.j2 +20 -0
- mcp4api/emit/templates/file.py.j2 +4 -0
- mcp4api/emit/templates/header.py.j2 +2 -0
- mcp4api/emit/templates/main.py.j2 +99 -0
- mcp4api/emit/templates/pyproject.toml.j2 +24 -0
- mcp4api/emit/templates/test_tools.py.j2 +40 -0
- mcp4api/errors.py +35 -0
- mcp4api/ingest/__init__.py +3 -0
- mcp4api/ingest/build.py +1064 -0
- mcp4api/ingest/convert.py +569 -0
- mcp4api/ingest/load.py +603 -0
- mcp4api/model.py +324 -0
- mcp4api/py.typed +0 -0
- mcp4api/report.py +330 -0
- mcp4api/suite.py +1384 -0
- mcp4api/verify.py +1114 -0
- mcp4api-0.1.0.dist-info/METADATA +157 -0
- mcp4api-0.1.0.dist-info/RECORD +36 -0
- mcp4api-0.1.0.dist-info/WHEEL +4 -0
- mcp4api-0.1.0.dist-info/entry_points.txt +2 -0
- mcp4api-0.1.0.dist-info/licenses/LICENSE +21 -0
mcp4api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
mcp4api/__main__.py
ADDED
mcp4api/cassettes.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from mcp4api.model import JsonObject, JsonValue, dump_json
|
|
8
|
+
|
|
9
|
+
_SECRET_NAME = re.compile(r"(KEY|TOKEN|SECRET|PASSWORD|AUTH)", re.IGNORECASE)
|
|
10
|
+
_BEARER = re.compile(r"(?i)\bbearer\s+[A-Za-z0-9._~+/=-]{8,}")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def write_cassettes(out: Path, results: JsonObject, secrets: list[str]) -> None:
|
|
14
|
+
"""Write one scrubbed cassette per tool.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
out: Artifact root. Cassettes are written under ``tests/cassettes``.
|
|
18
|
+
results: Verification result object with a ``calls`` array.
|
|
19
|
+
secrets: Secret values to replace before the cassette is stored.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
None.
|
|
23
|
+
"""
|
|
24
|
+
grouped: dict[str, list[JsonValue]] = {}
|
|
25
|
+
for call in _result_calls(results):
|
|
26
|
+
if call.get("kind") == "negative":
|
|
27
|
+
continue
|
|
28
|
+
tool = call.get("tool")
|
|
29
|
+
exchanges = call.get("exchanges")
|
|
30
|
+
if not isinstance(tool, str) or not isinstance(exchanges, list):
|
|
31
|
+
continue
|
|
32
|
+
grouped.setdefault(tool, []).extend(item for item in exchanges if isinstance(item, dict))
|
|
33
|
+
directory = out / "tests" / "cassettes"
|
|
34
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
for tool, rows in grouped.items():
|
|
36
|
+
cleaned = scrub(rows, secrets)
|
|
37
|
+
if not isinstance(cleaned, list):
|
|
38
|
+
continue
|
|
39
|
+
(directory / f"{tool}.json").write_text(
|
|
40
|
+
dump_json({"exchanges": cleaned}),
|
|
41
|
+
encoding="utf-8",
|
|
42
|
+
newline="\n",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def secret_values(names: tuple[str, ...]) -> list[str]:
|
|
47
|
+
"""Collect secret environment values that cassettes must not keep.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
names: Environment variable names known to hold credentials.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Secret values, longest first.
|
|
54
|
+
"""
|
|
55
|
+
wanted = set(names)
|
|
56
|
+
found: list[str] = []
|
|
57
|
+
for name, value in os.environ.items():
|
|
58
|
+
if (name in wanted or _SECRET_NAME.search(name)) and len(value) >= 4:
|
|
59
|
+
found.append(value)
|
|
60
|
+
return sorted(set(found), key=len, reverse=True)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def scrub(value: JsonValue, secrets: list[str]) -> JsonValue:
|
|
64
|
+
"""Replace secrets and bearer tokens in a JSON value.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
value: JSON value to scrub.
|
|
68
|
+
secrets: Secret strings to replace with ``***``.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
A copy of the value with those secrets removed.
|
|
72
|
+
"""
|
|
73
|
+
if isinstance(value, str):
|
|
74
|
+
return _scrub_text(value, secrets)
|
|
75
|
+
if isinstance(value, list):
|
|
76
|
+
return [scrub(item, secrets) for item in value]
|
|
77
|
+
if isinstance(value, dict):
|
|
78
|
+
result: JsonObject = {}
|
|
79
|
+
for key, item in value.items():
|
|
80
|
+
result[_scrub_text(key, secrets)] = scrub(item, secrets)
|
|
81
|
+
return result
|
|
82
|
+
return value
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _result_calls(results: JsonObject) -> list[JsonObject]:
|
|
86
|
+
raw = results.get("calls")
|
|
87
|
+
if not isinstance(raw, list):
|
|
88
|
+
return []
|
|
89
|
+
return [item for item in raw if isinstance(item, dict)]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _scrub_text(value: str, secrets: list[str]) -> str:
|
|
93
|
+
text = value
|
|
94
|
+
for secret in secrets:
|
|
95
|
+
text = text.replace(secret, "***")
|
|
96
|
+
return _BEARER.sub("Bearer ***", text)
|