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/emit/__init__.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
from pydantic import ValidationError
|
|
10
|
+
|
|
11
|
+
from mcp4api.curate.models import Manifest, load_manifest
|
|
12
|
+
from mcp4api.emit.render import render_files
|
|
13
|
+
from mcp4api.errors import UsageError
|
|
14
|
+
from mcp4api.ingest import read_endpoints
|
|
15
|
+
from mcp4api.model import dump_json
|
|
16
|
+
|
|
17
|
+
_LEDGER = ".mcp4api-files.json"
|
|
18
|
+
_GENERATED = "generated"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class Emitted:
|
|
23
|
+
package: str
|
|
24
|
+
base_url_env: str
|
|
25
|
+
timeout_env: str
|
|
26
|
+
redact_env_names: tuple[str, ...]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def emit(
|
|
30
|
+
out: Path,
|
|
31
|
+
*,
|
|
32
|
+
force: bool = False,
|
|
33
|
+
skip_tools: frozenset[str] | None = None,
|
|
34
|
+
description_prefixes: dict[str, str] | None = None,
|
|
35
|
+
) -> Emitted:
|
|
36
|
+
"""Write the generated MCP server for a curated manifest.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
out: Directory that contains tools.yaml and endpoints.json.
|
|
40
|
+
force: Overwrite generated files that were edited after the last emit.
|
|
41
|
+
skip_tools: Tool names to leave out of the server.
|
|
42
|
+
description_prefixes: Text prepended to those tools' descriptions.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Package name and the environment variable names written into the client.
|
|
46
|
+
"""
|
|
47
|
+
tools_path = out / "tools.yaml"
|
|
48
|
+
endpoints_path = out / "endpoints.json"
|
|
49
|
+
if not tools_path.is_file():
|
|
50
|
+
raise UsageError(f"not found: {tools_path}")
|
|
51
|
+
tools_bytes = _read(tools_path)
|
|
52
|
+
spec, _endpoints = read_endpoints(endpoints_path)
|
|
53
|
+
manifest = _load_manifest(tools_bytes)
|
|
54
|
+
selected = _select_tools(manifest, skip_tools or frozenset(), description_prefixes or {})
|
|
55
|
+
rendered = render_files(
|
|
56
|
+
spec,
|
|
57
|
+
manifest if selected is None else selected,
|
|
58
|
+
tools_sha256=hashlib.sha256(tools_bytes).hexdigest(),
|
|
59
|
+
)
|
|
60
|
+
_write_tree(out / _GENERATED, rendered.files, rendered.header, force=force)
|
|
61
|
+
return Emitted(
|
|
62
|
+
package=rendered.package,
|
|
63
|
+
base_url_env=rendered.base_url_env,
|
|
64
|
+
timeout_env=rendered.timeout_env,
|
|
65
|
+
redact_env_names=rendered.redact_env_names,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _select_tools(
|
|
70
|
+
manifest: Manifest,
|
|
71
|
+
skip_tools: frozenset[str],
|
|
72
|
+
description_prefixes: dict[str, str],
|
|
73
|
+
) -> Manifest | None:
|
|
74
|
+
if not skip_tools and not description_prefixes:
|
|
75
|
+
return None
|
|
76
|
+
tools = []
|
|
77
|
+
for tool in manifest.tools:
|
|
78
|
+
if tool.name in skip_tools:
|
|
79
|
+
continue
|
|
80
|
+
prefix = description_prefixes.get(tool.name, "")
|
|
81
|
+
if prefix:
|
|
82
|
+
tool = tool.model_copy(update={"description": f"{prefix}{tool.description}"})
|
|
83
|
+
tools.append(tool)
|
|
84
|
+
return manifest.model_copy(update={"tools": tools})
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _read(path: Path) -> bytes:
|
|
88
|
+
try:
|
|
89
|
+
return path.read_bytes()
|
|
90
|
+
except OSError as exc:
|
|
91
|
+
raise UsageError(f"not found: {path}") from exc
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _load_manifest(payload: bytes) -> Manifest:
|
|
95
|
+
try:
|
|
96
|
+
text = payload.decode("utf-8")
|
|
97
|
+
except UnicodeDecodeError as exc:
|
|
98
|
+
raise UsageError("tools.yaml is invalid") from exc
|
|
99
|
+
try:
|
|
100
|
+
return load_manifest(text)
|
|
101
|
+
except (yaml.YAMLError, ValidationError, ValueError, TypeError) as exc:
|
|
102
|
+
raise UsageError("tools.yaml is invalid") from exc
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _write_tree(root: Path, files: dict[str, str], header: str, *, force: bool) -> None:
|
|
106
|
+
encoded = {path: _encode(text) for path, text in files.items()}
|
|
107
|
+
recorded = _read_ledger(root / _LEDGER, force=force)
|
|
108
|
+
edited: list[str] = []
|
|
109
|
+
removable: list[str] = []
|
|
110
|
+
if recorded is not None:
|
|
111
|
+
for rel, digest in recorded.items():
|
|
112
|
+
path = _inside(root, rel)
|
|
113
|
+
changed = not path.is_file() or _digest(path.read_bytes()) != digest
|
|
114
|
+
if rel not in encoded:
|
|
115
|
+
if changed and not force:
|
|
116
|
+
edited.append(rel)
|
|
117
|
+
elif path.is_file() or not changed:
|
|
118
|
+
removable.append(rel)
|
|
119
|
+
continue
|
|
120
|
+
if changed:
|
|
121
|
+
edited.append(rel)
|
|
122
|
+
if edited and not force:
|
|
123
|
+
raise UsageError(_refuse(edited))
|
|
124
|
+
for rel in removable:
|
|
125
|
+
path = _inside(root, rel)
|
|
126
|
+
if path.is_file():
|
|
127
|
+
path.unlink()
|
|
128
|
+
_prune(root, path)
|
|
129
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
for rel in sorted(encoded):
|
|
131
|
+
path = root / rel
|
|
132
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
133
|
+
path.write_bytes(encoded[rel])
|
|
134
|
+
ledger = {"files": {rel: _digest(encoded[rel]) for rel in sorted(encoded)}, "header": header}
|
|
135
|
+
(root / _LEDGER).write_bytes(_encode(dump_json(ledger)))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _encode(text: str) -> bytes:
|
|
139
|
+
if "\r" in text:
|
|
140
|
+
raise UsageError("generated text contains a carriage return")
|
|
141
|
+
if not text.endswith("\n"):
|
|
142
|
+
text += "\n"
|
|
143
|
+
return text.encode("utf-8")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _digest(payload: bytes) -> str:
|
|
147
|
+
return hashlib.sha256(payload).hexdigest()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _read_ledger(path: Path, *, force: bool) -> dict[str, str] | None:
|
|
151
|
+
if not path.is_file():
|
|
152
|
+
return None
|
|
153
|
+
try:
|
|
154
|
+
text = path.read_text(encoding="utf-8")
|
|
155
|
+
loaded: object = json.loads(text)
|
|
156
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
157
|
+
if force:
|
|
158
|
+
return None
|
|
159
|
+
raise UsageError(f"{_GENERATED}/{_LEDGER} is invalid") from exc
|
|
160
|
+
if not isinstance(loaded, dict):
|
|
161
|
+
if force:
|
|
162
|
+
return None
|
|
163
|
+
raise UsageError(f"{_GENERATED}/{_LEDGER} is invalid")
|
|
164
|
+
files = loaded.get("files")
|
|
165
|
+
if not isinstance(files, dict):
|
|
166
|
+
if force:
|
|
167
|
+
return None
|
|
168
|
+
raise UsageError(f"{_GENERATED}/{_LEDGER} is invalid")
|
|
169
|
+
recorded: dict[str, str] = {}
|
|
170
|
+
for key, value in files.items():
|
|
171
|
+
if not isinstance(key, str) or not isinstance(value, str) or not _relative(key):
|
|
172
|
+
if force:
|
|
173
|
+
return None
|
|
174
|
+
raise UsageError(f"{_GENERATED}/{_LEDGER} is invalid")
|
|
175
|
+
recorded[key] = value
|
|
176
|
+
return recorded
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _relative(rel: str) -> bool:
|
|
180
|
+
path = Path(rel)
|
|
181
|
+
return not path.is_absolute() and ".." not in path.parts and rel not in {"", "."}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _inside(root: Path, rel: str) -> Path:
|
|
185
|
+
if not _relative(rel):
|
|
186
|
+
raise UsageError(f"{_GENERATED}/{_LEDGER} is invalid")
|
|
187
|
+
return root / rel
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _prune(root: Path, path: Path) -> None:
|
|
191
|
+
current = path.parent
|
|
192
|
+
while current != root:
|
|
193
|
+
if any(current.iterdir()):
|
|
194
|
+
return
|
|
195
|
+
current.rmdir()
|
|
196
|
+
current = current.parent
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _refuse(paths: list[str]) -> str:
|
|
200
|
+
lines = [
|
|
201
|
+
"refusing to overwrite edited files:",
|
|
202
|
+
*[f"{_GENERATED}/{path}" for path in sorted(paths)],
|
|
203
|
+
]
|
|
204
|
+
lines.append("Change tools.yaml or overrides.yaml and run mcp4api again, or pass --force.")
|
|
205
|
+
return "\n".join(lines)
|