mcpcert-cli 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,96 @@
1
+ """`mcpcert update`. Mirrors packages/node/src/commands/update.ts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ from ..api import ApiClient
8
+ from ..args import ParsedArgs, many, one
9
+ from ..config import discover_config, write_config
10
+ from ..context import resolve_api_base_url
11
+ from ..contract import promotion_url
12
+ from ..credentials import default_credentials_path, get_identity
13
+ from ..errors import CliError
14
+ from ..output import Output
15
+
16
+ SCALAR_FIELDS = [
17
+ ("client-name", "client_name"),
18
+ ("client-uri", "client_uri"),
19
+ ("logo-uri", "logo_uri"),
20
+ ("tos-uri", "tos_uri"),
21
+ ("policy-uri", "policy_uri"),
22
+ ("scope", "scope"),
23
+ ]
24
+
25
+
26
+ def run(args: ParsedArgs, output: Output) -> int:
27
+ cwd = os.getcwd()
28
+ explicit_config = one(args, "config") or os.environ.get("MCPCERT_CONFIG")
29
+ discovered = discover_config(cwd, explicit_config)
30
+ if not discovered.config or not discovered.config_path or not discovered.fmt:
31
+ raise CliError("config_not_found", "No mcpcert development identity is configured here. Run `mcpcert init` first.")
32
+ config = discovered.config
33
+ api_base_url = resolve_api_base_url(args, config)
34
+
35
+ patch: dict[str, object] = {}
36
+ updated_fields: list[str] = []
37
+ for flag, field in SCALAR_FIELDS:
38
+ value = one(args, flag)
39
+ if value is not None:
40
+ patch[field] = value
41
+ updated_fields.append(field)
42
+ redirect_uris = many(args, "redirect-uri")
43
+ if redirect_uris is not None:
44
+ patch["redirect_uris"] = redirect_uris
45
+ updated_fields.append("redirect_uris")
46
+ contacts = many(args, "contact")
47
+ if contacts is not None:
48
+ patch["contacts"] = contacts
49
+ updated_fields.append("contacts")
50
+ if not updated_fields:
51
+ raise CliError("no_update_fields", "Nothing to update. Pass at least one mutable field, e.g. --redirect-uri or --client-name.")
52
+
53
+ creds_path = one(args, "credentials") or os.environ.get("MCPCERT_CREDENTIALS") or default_credentials_path(discovered.project_root)
54
+ identity = get_identity(creds_path, api_base_url, config["appname"])
55
+ if not identity:
56
+ raise CliError("claim_token_missing", "No claim token found for this identity. The token is shown once at creation and cannot be recovered.")
57
+
58
+ api = ApiClient(api_base_url)
59
+ updated = api.update(config["appname"], identity["claim_token"], patch)
60
+
61
+ next_config = dict(config)
62
+ if "client_name" in patch:
63
+ next_config["client_name"] = patch["client_name"]
64
+ if "redirect_uris" in patch:
65
+ next_config["redirect_uris"] = patch["redirect_uris"]
66
+ next_config["client_id"] = updated.get("client_id", config["client_id"])
67
+ next_config["document_urls"] = updated.get("document_urls", config["document_urls"])
68
+ next_config["expires_at"] = updated.get("expires_at", config["expires_at"])
69
+ write_config(discovered.config_path, discovered.fmt, next_config)
70
+
71
+ promote = promotion_url(api_base_url, config["appname"])
72
+ output.success(
73
+ "update",
74
+ api_base_url,
75
+ {
76
+ "appname": config["appname"],
77
+ "environment": "development",
78
+ "application_type": config["application_type"],
79
+ "client_name": next_config["client_name"],
80
+ "client_id": next_config["client_id"],
81
+ "document_urls": next_config["document_urls"],
82
+ "expires_at": next_config["expires_at"],
83
+ "promotion_url": promote,
84
+ "updated_fields": updated_fields,
85
+ "api_version": api.api_version,
86
+ },
87
+ [
88
+ "Development identity updated",
89
+ f"Appname: {config['appname']}",
90
+ f"Client ID: {next_config['client_id']}",
91
+ f"Updated: {', '.join(updated_fields)}",
92
+ f"Expires: {next_config['expires_at']}",
93
+ f"Promote: {promote}",
94
+ ],
95
+ )
96
+ return 0
mcpcert/config.py ADDED
@@ -0,0 +1,318 @@
1
+ """Config discovery, metadata resolution, and JSON/TOML persistence.
2
+
3
+ Mirrors packages/node/src/lib/config.ts so both CLIs behave identically.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+ import re
11
+ from dataclasses import dataclass
12
+ from typing import Any
13
+
14
+ from .contract import CONFIG_SCHEMA_VERSION
15
+ from .errors import CliError
16
+
17
+ try: # Python 3.11+
18
+ import tomllib as _toml
19
+ except ModuleNotFoundError: # Python 3.10
20
+ import tomli as _toml # type: ignore[no-redef]
21
+
22
+ APPNAME_MAX = 63
23
+ CONFIG_KEYS_ORDER = [
24
+ "schema_version",
25
+ "appname",
26
+ "environment",
27
+ "application_type",
28
+ "client_name",
29
+ "project_slug",
30
+ "redirect_uris",
31
+ "client_id",
32
+ "expires_at",
33
+ "api_base_url",
34
+ ]
35
+
36
+
37
+ @dataclass
38
+ class ResolvedConfig:
39
+ config: dict[str, Any] | None
40
+ config_path: str | None
41
+ fmt: str | None # "json" | "toml"
42
+ project_root: str
43
+
44
+
45
+ @dataclass
46
+ class ResolvedMetadata:
47
+ client_name: str
48
+ project_slug: str
49
+
50
+
51
+ def slugify(value: str) -> str:
52
+ slug = value.lower()
53
+ slug = re.sub(r"[^a-z0-9-]+", "-", slug)
54
+ slug = re.sub(r"-+", "-", slug)
55
+ slug = slug.strip("-")
56
+ if len(slug) > APPNAME_MAX:
57
+ slug = slug[:APPNAME_MAX].rstrip("-")
58
+ return slug or "mcp-client"
59
+
60
+
61
+ def _walk_up(start: str) -> list[str]:
62
+ dirs: list[str] = []
63
+ current = os.path.abspath(start)
64
+ while True:
65
+ dirs.append(current)
66
+ parent = os.path.dirname(current)
67
+ if parent == current:
68
+ break
69
+ current = parent
70
+ return dirs
71
+
72
+
73
+ def _read_toml(path: str) -> dict[str, Any] | None:
74
+ try:
75
+ with open(path, "rb") as handle:
76
+ return _toml.load(handle)
77
+ except (OSError, ValueError, _toml.TOMLDecodeError): # type: ignore[attr-defined]
78
+ return None
79
+
80
+
81
+ def _read_json(path: str) -> dict[str, Any] | None:
82
+ try:
83
+ with open(path, encoding="utf-8") as handle:
84
+ return json.load(handle)
85
+ except (OSError, ValueError):
86
+ return None
87
+
88
+
89
+ def _pyproject_name(parsed: dict[str, Any] | None) -> str | None:
90
+ if not parsed:
91
+ return None
92
+ project = parsed.get("project")
93
+ if isinstance(project, dict) and isinstance(project.get("name"), str):
94
+ return project["name"]
95
+ poetry = parsed.get("tool", {}).get("poetry") if isinstance(parsed.get("tool"), dict) else None
96
+ if isinstance(poetry, dict) and isinstance(poetry.get("name"), str):
97
+ return poetry["name"]
98
+ return None
99
+
100
+
101
+ def _package_json_name(parsed: dict[str, Any] | None) -> str | None:
102
+ if not parsed or not isinstance(parsed.get("name"), str):
103
+ return None
104
+ return re.sub(r"^@[^/]+/", "", parsed["name"])
105
+
106
+
107
+ def resolve_metadata(
108
+ cwd: str,
109
+ client_name_flag: str | None,
110
+ project_name_flag: str | None,
111
+ config: dict[str, Any] | None,
112
+ ) -> ResolvedMetadata:
113
+ py_name: str | None = None
114
+ pkg_name: str | None = None
115
+ for directory in _walk_up(cwd):
116
+ py_path = os.path.join(directory, "pyproject.toml")
117
+ if py_name is None and os.path.exists(py_path):
118
+ py_name = _pyproject_name(_read_toml(py_path))
119
+ pkg_path = os.path.join(directory, "package.json")
120
+ if pkg_name is None and os.path.exists(pkg_path):
121
+ pkg_name = _package_json_name(_read_json(pkg_path))
122
+ if py_name is not None and pkg_name is not None:
123
+ break
124
+ base = (
125
+ (config.get("client_name") if config else None)
126
+ or py_name
127
+ or pkg_name
128
+ or os.path.basename(os.path.abspath(cwd))
129
+ or "mcp-client"
130
+ )
131
+ client_name = client_name_flag or project_name_flag or base
132
+ project_slug = slugify(project_name_flag or client_name_flag or base)
133
+ return ResolvedMetadata(client_name=client_name, project_slug=project_slug)
134
+
135
+
136
+ def _coerce_config(raw: dict[str, Any], path: str) -> dict[str, Any]:
137
+ required = ["appname", "environment", "application_type", "client_name", "client_id", "api_base_url"]
138
+ for key in required:
139
+ if raw.get(key) is None:
140
+ raise CliError("config_invalid", f'Config at {path} is missing required field "{key}".')
141
+ return raw
142
+
143
+
144
+ def _read_mcpcert_from_toml(path: str) -> dict[str, Any] | None:
145
+ parsed = _read_toml(path)
146
+ if not parsed:
147
+ return None
148
+ section = parsed.get("tool", {}).get("mcpcert") if isinstance(parsed.get("tool"), dict) else None
149
+ if not isinstance(section, dict):
150
+ return None
151
+ return _coerce_config(section, path)
152
+
153
+
154
+ def discover_config(cwd: str, explicit_path: str | None = None) -> ResolvedConfig:
155
+ if explicit_path:
156
+ if explicit_path.endswith(".toml"):
157
+ fmt = "toml"
158
+ elif explicit_path.endswith(".json"):
159
+ fmt = "json"
160
+ else:
161
+ raise CliError("unsupported_config_path", f"--config must point to a .json or .toml file: {explicit_path}")
162
+ if not os.path.exists(explicit_path):
163
+ return ResolvedConfig(None, explicit_path, fmt, os.path.dirname(explicit_path))
164
+ config = _read_mcpcert_from_toml(explicit_path) if fmt == "toml" else _coerce_config(_read_json(explicit_path) or {}, explicit_path)
165
+ return ResolvedConfig(config, explicit_path, fmt, os.path.dirname(explicit_path))
166
+
167
+ for directory in _walk_up(cwd):
168
+ json_path = os.path.join(directory, "mcpcert.json")
169
+ toml_path = os.path.join(directory, "pyproject.toml")
170
+ has_json = os.path.exists(json_path)
171
+ toml_config = _read_mcpcert_from_toml(toml_path) if os.path.exists(toml_path) else None
172
+ if not has_json and not toml_config:
173
+ continue
174
+ json_config = _coerce_config(_read_json(json_path) or {}, json_path) if has_json else None
175
+ if json_config and toml_config:
176
+ if _normalize(json_config) != _normalize(toml_config):
177
+ raise CliError(
178
+ "config_conflict",
179
+ f"Both mcpcert.json and [tool.mcpcert] exist in {directory} with different values. Remove one.",
180
+ )
181
+ return ResolvedConfig(json_config, json_path, "json", directory)
182
+ if json_config:
183
+ return ResolvedConfig(json_config, json_path, "json", directory)
184
+ return ResolvedConfig(toml_config, toml_path, "toml", directory)
185
+ return ResolvedConfig(None, None, None, os.path.abspath(cwd))
186
+
187
+
188
+ def _normalize(config: dict[str, Any]) -> dict[str, Any]:
189
+ keys = [
190
+ "appname",
191
+ "environment",
192
+ "application_type",
193
+ "client_name",
194
+ "project_slug",
195
+ "redirect_uris",
196
+ "client_id",
197
+ "document_urls",
198
+ "expires_at",
199
+ "api_base_url",
200
+ ]
201
+ return {key: config.get(key) for key in keys}
202
+
203
+
204
+ def choose_write_target(project_root: str, explicit_path: str | None = None) -> tuple[str, str]:
205
+ if explicit_path:
206
+ if explicit_path.endswith(".toml"):
207
+ return explicit_path, "toml"
208
+ if explicit_path.endswith(".json"):
209
+ return explicit_path, "json"
210
+ raise CliError("unsupported_config_path", f"--config must point to a .json or .toml file: {explicit_path}")
211
+ pyproject = os.path.join(project_root, "pyproject.toml")
212
+ if os.path.exists(pyproject):
213
+ parsed = _read_toml(pyproject) or {}
214
+ if "project" in parsed or (isinstance(parsed.get("tool"), dict) and "poetry" in parsed["tool"]):
215
+ return pyproject, "toml"
216
+ return os.path.join(project_root, "mcpcert.json"), "json"
217
+
218
+
219
+ def _order_config(config: dict[str, Any]) -> dict[str, Any]:
220
+ ordered = {key: config.get(key) for key in CONFIG_KEYS_ORDER}
221
+ ordered["document_urls"] = config.get("document_urls")
222
+ return ordered
223
+
224
+
225
+ def _tstr(value: str) -> str:
226
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"')
227
+ return f'"{escaped}"'
228
+
229
+
230
+ def _serialize_mcpcert_toml(c: dict[str, Any]) -> str:
231
+ redirects = ", ".join(_tstr(u) for u in c["redirect_uris"])
232
+ docs = c["document_urls"]
233
+ lines = [
234
+ "[tool.mcpcert]",
235
+ f"schema_version = {c['schema_version']}",
236
+ f"appname = {_tstr(c['appname'])}",
237
+ f"environment = {_tstr(c['environment'])}",
238
+ f"application_type = {_tstr(c['application_type'])}",
239
+ f"client_name = {_tstr(c['client_name'])}",
240
+ f"project_slug = {_tstr(c['project_slug'])}",
241
+ f"redirect_uris = [{redirects}]",
242
+ f"client_id = {_tstr(c['client_id'])}",
243
+ f"expires_at = {_tstr(c['expires_at'])}",
244
+ f"api_base_url = {_tstr(c['api_base_url'])}",
245
+ "",
246
+ "[tool.mcpcert.document_urls]",
247
+ f"cimd = {_tstr(docs['cimd'])}",
248
+ f"aasa = {_tstr(docs['aasa'])}",
249
+ f"assetlinks = {_tstr(docs['assetlinks'])}",
250
+ "",
251
+ ]
252
+ return "\n".join(lines)
253
+
254
+
255
+ def _strip_mcpcert_sections(text: str) -> str:
256
+ if not text:
257
+ return ""
258
+ out: list[str] = []
259
+ skipping = False
260
+ for line in text.split("\n"):
261
+ match = re.match(r"^\s*\[\s*([^\]]+?)\s*\]\s*$", line)
262
+ if match:
263
+ name = match.group(1)
264
+ skipping = name == "tool.mcpcert" or name.startswith("tool.mcpcert.")
265
+ if not skipping:
266
+ out.append(line)
267
+ joined = "\n".join(out)
268
+ joined = re.sub(r"\n{3,}", "\n\n", joined)
269
+ return joined.rstrip()
270
+
271
+
272
+ def write_config(path: str, fmt: str, config: dict[str, Any]) -> None:
273
+ if fmt == "json":
274
+ with open(path, "w", encoding="utf-8") as handle:
275
+ handle.write(json.dumps(_order_config(config), indent=2) + "\n")
276
+ return
277
+ existing = ""
278
+ if os.path.exists(path):
279
+ with open(path, encoding="utf-8") as handle:
280
+ existing = handle.read()
281
+ stripped = _strip_mcpcert_sections(existing)
282
+ if not stripped:
283
+ sep = ""
284
+ elif stripped.endswith("\n\n"):
285
+ sep = ""
286
+ elif stripped.endswith("\n"):
287
+ sep = "\n"
288
+ else:
289
+ sep = "\n\n"
290
+ with open(path, "w", encoding="utf-8") as handle:
291
+ handle.write(f"{stripped}{sep}{_serialize_mcpcert_toml(config)}")
292
+
293
+
294
+ def build_config(
295
+ *,
296
+ appname: str,
297
+ application_type: str,
298
+ client_name: str,
299
+ project_slug: str,
300
+ redirect_uris: list[str],
301
+ client_id: str,
302
+ document_urls: dict[str, str],
303
+ expires_at: str,
304
+ api_base_url: str,
305
+ ) -> dict[str, Any]:
306
+ return {
307
+ "schema_version": CONFIG_SCHEMA_VERSION,
308
+ "appname": appname,
309
+ "environment": "development",
310
+ "application_type": application_type,
311
+ "client_name": client_name,
312
+ "project_slug": project_slug,
313
+ "redirect_uris": redirect_uris,
314
+ "client_id": client_id,
315
+ "document_urls": document_urls,
316
+ "expires_at": expires_at,
317
+ "api_base_url": api_base_url,
318
+ }