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.
@@ -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)