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.
- mcpcert/__init__.py +3 -0
- mcpcert/api.py +112 -0
- mcpcert/args.py +61 -0
- mcpcert/cli.py +89 -0
- mcpcert/commands/__init__.py +0 -0
- mcpcert/commands/conformance_cmd.py +128 -0
- mcpcert/commands/info_cmd.py +91 -0
- mcpcert/commands/init_cmd.py +148 -0
- mcpcert/commands/update_cmd.py +96 -0
- mcpcert/config.py +318 -0
- mcpcert/conformance.py +341 -0
- mcpcert/context.py +31 -0
- mcpcert/contract.py +61 -0
- mcpcert/credentials.py +79 -0
- mcpcert/errors.py +23 -0
- mcpcert/output.py +66 -0
- mcpcert_cli-0.1.0.dist-info/METADATA +65 -0
- mcpcert_cli-0.1.0.dist-info/RECORD +22 -0
- mcpcert_cli-0.1.0.dist-info/WHEEL +4 -0
- mcpcert_cli-0.1.0.dist-info/entry_points.txt +2 -0
- mcpcert_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
- mcpcert_cli-0.1.0.dist-info/licenses/NOTICE +8 -0
|
@@ -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
|
+
}
|