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/conformance.py ADDED
@@ -0,0 +1,341 @@
1
+ """Conformance normalization + the built-in OAuth flow driver.
2
+
3
+ Mirrors packages/node/src/lib/conformance.ts. The OAuth/PKCE/loopback helpers are
4
+ self-contained here; the CLI depends only on the public mcpcert.org HTTP API.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import hashlib
11
+ import json
12
+ import secrets
13
+ import threading
14
+ import time
15
+ import urllib.error
16
+ import urllib.parse
17
+ import urllib.request
18
+ import webbrowser
19
+ from datetime import datetime, timezone
20
+ from http.server import BaseHTTPRequestHandler, HTTPServer
21
+ from typing import Any, Callable
22
+
23
+ from .errors import CliError
24
+
25
+
26
+ def iso_now() -> str:
27
+ now = datetime.now(timezone.utc)
28
+ return now.strftime("%Y-%m-%dT%H:%M:%S.") + f"{now.microsecond // 1000:03d}Z"
29
+
30
+
31
+ def verdict_from_status(status: str) -> str:
32
+ if status in ("pass", "fail", "error"):
33
+ return status
34
+ return "incomplete"
35
+
36
+
37
+ def normalize_run(api: dict[str, Any]) -> dict[str, Any]:
38
+ trace = api.get("trace")
39
+ if not isinstance(trace, list):
40
+ raise CliError("conformance_payload_invalid", "Server returned a conformance run without a steps array.")
41
+ steps: list[dict[str, Any]] = []
42
+ summary = {"total": 0, "pass": 0, "fail": 0, "warn": 0, "skip": 0}
43
+ for step in trace:
44
+ normalized = {"id": step.get("id"), "title": step.get("title"), "status": step.get("status")}
45
+ if step.get("detail") is not None:
46
+ normalized["detail"] = step["detail"]
47
+ if step.get("remediation") is not None:
48
+ normalized["remediation"] = step["remediation"]
49
+ steps.append(normalized)
50
+ summary["total"] += 1
51
+ if step.get("status") in summary:
52
+ summary[step["status"]] += 1
53
+ run: dict[str, Any] = {
54
+ "run_id": api.get("runId"),
55
+ "client_id": api.get("clientId"),
56
+ "started_at": api.get("createdAt"),
57
+ "status": api.get("status"),
58
+ "verdict": verdict_from_status(str(api.get("status"))),
59
+ "steps": steps,
60
+ "summary": summary,
61
+ }
62
+ if api.get("completedAt") is not None:
63
+ run["completed_at"] = api["completedAt"]
64
+ return run
65
+
66
+
67
+ def run_list_row(api: dict[str, Any]) -> dict[str, Any]:
68
+ row: dict[str, Any] = {
69
+ "run_id": api.get("runId"),
70
+ "client_id": api.get("clientId"),
71
+ "started_at": api.get("createdAt"),
72
+ "status": api.get("status"),
73
+ "verdict": verdict_from_status(str(api.get("status"))),
74
+ }
75
+ if api.get("completedAt") is not None:
76
+ row["completed_at"] = api["completedAt"]
77
+ return row
78
+
79
+
80
+ def resolve_sandbox_target(api_base_url: str, profile: str, dev_origin: str | None) -> dict[str, Any]:
81
+ if profile == "local":
82
+ origin = (dev_origin or "http://127.0.0.1:3000").rstrip("/")
83
+ return {
84
+ "profile": "local",
85
+ "transport_origin": origin,
86
+ "discovery_url": f"{origin}/sandbox/.well-known/oauth-authorization-server",
87
+ "rebase": True,
88
+ }
89
+ host = urllib.parse.urlparse(api_base_url).netloc
90
+ origin = f"https://sandbox.{host}"
91
+ return {
92
+ "profile": "production",
93
+ "transport_origin": origin,
94
+ "discovery_url": f"{origin}/.well-known/oauth-authorization-server",
95
+ "rebase": False,
96
+ }
97
+
98
+
99
+ def run_conformance_flow(
100
+ *,
101
+ config: dict[str, Any],
102
+ claim_token: str,
103
+ api_client: Any,
104
+ api_base_url: str,
105
+ profile: str,
106
+ dev_origin: str | None,
107
+ redirect_uri_flag: str | None,
108
+ no_open: bool,
109
+ timeout_ms: int,
110
+ emit: Callable[[str], None],
111
+ ) -> dict[str, Any]:
112
+ started_at = iso_now()
113
+ target = resolve_sandbox_target(api_base_url, profile, dev_origin)
114
+ cimd_url = config["client_id"]
115
+
116
+ metadata = _fetch_cimd(cimd_url)
117
+ if metadata.get("client_id") != cimd_url:
118
+ raise CliError("conformance_payload_invalid", f"CIMD client_id {metadata.get('client_id')} does not match configured {cimd_url}.")
119
+
120
+ endpoints = _discover_as(target)
121
+
122
+ redirect_template = redirect_uri_flag or _first_loopback(config["redirect_uris"])
123
+ if not redirect_template:
124
+ raise CliError("port_unavailable", "No loopback (http://127.0.0.1) redirect URI is configured. Add one with `mcpcert update --redirect-uri`.")
125
+
126
+ loopback = _start_loopback(redirect_template, timeout_ms)
127
+ try:
128
+ code_verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode()
129
+ code_challenge = base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest()).rstrip(b"=").decode()
130
+ state = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode()
131
+ resource = f"{endpoints['issuer']}/mcp"
132
+
133
+ params = {
134
+ "response_type": "code",
135
+ "client_id": cimd_url,
136
+ "redirect_uri": loopback["redirect_uri"],
137
+ "code_challenge": code_challenge,
138
+ "code_challenge_method": "S256",
139
+ "state": state,
140
+ "resource": resource,
141
+ }
142
+ authorize_url = f"{endpoints['authorize']}?{urllib.parse.urlencode(params)}"
143
+
144
+ if no_open:
145
+ emit(f"Authorize URL: {authorize_url}")
146
+ else:
147
+ try:
148
+ webbrowser.open(authorize_url)
149
+ except Exception: # noqa: BLE001 - browser launch is best-effort
150
+ pass
151
+ emit("Opened the authorization URL in your browser. Approve consent to continue...")
152
+
153
+ callback = loopback["wait"]()
154
+ if callback.get("error"):
155
+ desc = f" - {callback['error_description']}" if callback.get("error_description") else ""
156
+ raise CliError("validation_error", f"Authorization failed: {callback['error']}{desc}")
157
+ if not callback.get("code"):
158
+ raise CliError("validation_error", "Authorization callback did not include a code.")
159
+ if callback.get("state") != state:
160
+ raise CliError("validation_error", "State mismatch on authorization callback (possible CSRF).")
161
+
162
+ access_token = _exchange_token(
163
+ endpoints["token"],
164
+ code=callback["code"],
165
+ redirect_uri=loopback["redirect_uri"],
166
+ client_id=cimd_url,
167
+ code_verifier=code_verifier,
168
+ )
169
+ _mcp_ping(endpoints["mcp"], access_token)
170
+ finally:
171
+ loopback["close"]()
172
+
173
+ deadline = time.time() + timeout_ms / 1000
174
+ run: dict[str, Any] | None = None
175
+ while time.time() < deadline:
176
+ listing = api_client.conformance_list(config["appname"], claim_token)
177
+ matches = [
178
+ r for r in listing.get("runs", [])
179
+ if r.get("clientId") == cimd_url and str(r.get("createdAt", "")) >= started_at
180
+ ]
181
+ matches.sort(key=lambda r: r.get("createdAt", ""), reverse=True)
182
+ if matches:
183
+ run = normalize_run(matches[0])
184
+ break
185
+ time.sleep(2)
186
+
187
+ return {"started_at": started_at, "run": run, "run_lookup_timed_out": run is None}
188
+
189
+
190
+ # ---- duplicated OAuth helpers ----
191
+
192
+ def _http_get_json(url: str, what: str) -> dict[str, Any]:
193
+ try:
194
+ request = urllib.request.Request(url, headers={"accept": "application/json"})
195
+ with urllib.request.urlopen(request) as response:
196
+ return json.loads(response.read().decode("utf-8"))
197
+ except urllib.error.HTTPError as error:
198
+ raise CliError("conformance_payload_invalid", f"{what} returned HTTP {error.code}.") from None
199
+ except urllib.error.URLError as error:
200
+ raise CliError("network_error", f"{what} failed: {error.reason}") from None
201
+ except ValueError:
202
+ raise CliError("conformance_payload_invalid", f"{what} did not return valid JSON.") from None
203
+
204
+
205
+ def _fetch_cimd(cimd_url: str) -> dict[str, Any]:
206
+ return _http_get_json(cimd_url, "CIMD fetch")
207
+
208
+
209
+ def _discover_as(target: dict[str, Any]) -> dict[str, str]:
210
+ meta = _http_get_json(target["discovery_url"], "AS discovery")
211
+ issuer = str(meta.get("issuer") or "")
212
+ if not issuer:
213
+ raise CliError("conformance_payload_invalid", "AS metadata is missing issuer.")
214
+ origin = target["transport_origin"]
215
+ if target["rebase"]:
216
+ return {
217
+ "issuer": issuer,
218
+ "authorize": f"{origin}/sandbox/authorize",
219
+ "token": f"{origin}/sandbox/token",
220
+ "mcp": f"{origin}/sandbox/mcp",
221
+ }
222
+ return {
223
+ "issuer": issuer,
224
+ "authorize": str(meta.get("authorization_endpoint")),
225
+ "token": str(meta.get("token_endpoint")),
226
+ "mcp": f"{origin}/mcp",
227
+ }
228
+
229
+
230
+ def _first_loopback(redirect_uris: list[str]) -> str | None:
231
+ for uri in redirect_uris:
232
+ parsed = urllib.parse.urlparse(uri)
233
+ if parsed.scheme == "http" and parsed.hostname in ("127.0.0.1", "::1"):
234
+ return uri
235
+ return None
236
+
237
+
238
+ def _start_loopback(template: str, timeout_ms: int) -> dict[str, Any]:
239
+ parsed = urllib.parse.urlparse(template)
240
+ expected_path = parsed.path or "/"
241
+ host = parsed.hostname or "127.0.0.1"
242
+ requested_port = parsed.port or 0
243
+ captured: dict[str, Any] = {}
244
+
245
+ class _Handler(BaseHTTPRequestHandler):
246
+ def do_GET(self) -> None: # noqa: N802
247
+ url = urllib.parse.urlparse(self.path)
248
+ if url.path != expected_path:
249
+ self.send_response(404)
250
+ self.end_headers()
251
+ self.wfile.write(b"Not the OAuth callback path.")
252
+ return
253
+ query = urllib.parse.parse_qs(url.query)
254
+ captured.update(
255
+ code=query.get("code", [None])[0],
256
+ state=query.get("state", [None])[0],
257
+ error=query.get("error", [None])[0],
258
+ error_description=query.get("error_description", [None])[0],
259
+ )
260
+ self.send_response(200)
261
+ self.send_header("content-type", "text/html; charset=utf-8")
262
+ self.end_headers()
263
+ self.wfile.write(b"<!doctype html><title>mcpcert</title><p>Authorization complete. You may close this window.</p>")
264
+
265
+ def log_message(self, *args: Any) -> None: # silence default logging
266
+ return
267
+
268
+ server = HTTPServer((host, requested_port), _Handler)
269
+ actual_port = server.server_address[1]
270
+ redirect = parsed._replace(netloc=f"{host}:{actual_port}")
271
+ redirect_uri = urllib.parse.urlunparse(redirect)
272
+
273
+ def wait() -> dict[str, Any]:
274
+ deadline = time.time() + timeout_ms / 1000
275
+ server.timeout = 1
276
+ while not captured and time.time() < deadline:
277
+ server.handle_request()
278
+ if not captured:
279
+ raise CliError("callback_timeout", f"Timed out after {timeout_ms}ms waiting for the authorization callback.")
280
+ return captured
281
+
282
+ def close() -> None:
283
+ server.server_close()
284
+
285
+ return {"redirect_uri": redirect_uri, "wait": wait, "close": close}
286
+
287
+
288
+ def _exchange_token(token_endpoint: str, *, code: str, redirect_uri: str, client_id: str, code_verifier: str) -> str:
289
+ body = urllib.parse.urlencode(
290
+ {
291
+ "grant_type": "authorization_code",
292
+ "code": code,
293
+ "redirect_uri": redirect_uri,
294
+ "client_id": client_id,
295
+ "code_verifier": code_verifier,
296
+ }
297
+ ).encode()
298
+ request = urllib.request.Request(
299
+ token_endpoint,
300
+ data=body,
301
+ method="POST",
302
+ headers={"content-type": "application/x-www-form-urlencoded", "accept": "application/json"},
303
+ )
304
+ try:
305
+ with urllib.request.urlopen(request) as response:
306
+ payload = json.loads(response.read().decode("utf-8"))
307
+ except urllib.error.HTTPError as error:
308
+ payload = {}
309
+ try:
310
+ payload = json.loads(error.read().decode("utf-8"))
311
+ except ValueError:
312
+ pass
313
+ err = payload.get("error", f"HTTP {error.code}")
314
+ desc = payload.get("error_description", "token endpoint rejected the request")
315
+ raise CliError("validation_error", f"Token exchange failed: {err} - {desc}") from None
316
+ except urllib.error.URLError as error:
317
+ raise CliError("network_error", f"Token exchange failed: {error.reason}") from None
318
+ token = payload.get("access_token")
319
+ if not isinstance(token, str):
320
+ raise CliError("validation_error", "Token response is missing access_token.")
321
+ return token
322
+
323
+
324
+ def _mcp_ping(mcp_endpoint: str, access_token: str) -> None:
325
+ request = urllib.request.Request(
326
+ mcp_endpoint,
327
+ data=json.dumps({"tool": "ping", "params": {}}).encode(),
328
+ method="POST",
329
+ headers={
330
+ "authorization": f"Bearer {access_token}",
331
+ "content-type": "application/json",
332
+ "accept": "application/json",
333
+ },
334
+ )
335
+ try:
336
+ with urllib.request.urlopen(request):
337
+ return
338
+ except urllib.error.HTTPError as error:
339
+ raise CliError("validation_error", f"MCP ping failed with HTTP {error.code}.") from None
340
+ except urllib.error.URLError as error:
341
+ raise CliError("network_error", f"MCP ping failed: {error.reason}") from None
mcpcert/context.py ADDED
@@ -0,0 +1,31 @@
1
+ """Shared resolution helpers. Mirrors packages/node/src/lib/context.ts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ from typing import Any
8
+
9
+ from .args import ParsedArgs, one
10
+ from .contract import DEFAULT_API_BASE_URL
11
+
12
+
13
+ def resolve_api_base_url(args: ParsedArgs, config: dict[str, Any] | None) -> str:
14
+ raw = (
15
+ one(args, "api")
16
+ or os.environ.get("MCPCERT_API")
17
+ or (config.get("api_base_url") if config else None)
18
+ or DEFAULT_API_BASE_URL
19
+ )
20
+ return raw.rstrip("/")
21
+
22
+
23
+ def is_non_interactive() -> bool:
24
+ ci = os.environ.get("CI")
25
+ if ci and ci not in ("false", "0"):
26
+ return True
27
+ return not sys.stdout.isatty() or not sys.stdin.isatty()
28
+
29
+
30
+ def env_flag(name: str) -> bool:
31
+ return os.environ.get(name) in ("1", "true", "yes")
mcpcert/contract.py ADDED
@@ -0,0 +1,61 @@
1
+ """Shared contract constants. Mirrors packages/node/src/lib/contract.ts and specs/cli-behavior.md."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from urllib.parse import quote
6
+
7
+ CLI_CONTRACT_VERSION = "0.1"
8
+ CONFIG_SCHEMA_VERSION = 1
9
+ CREDENTIALS_SCHEMA_VERSION = 1
10
+ OUTPUT_SCHEMA_VERSION = 1
11
+ DEFAULT_API_BASE_URL = "https://mcpcert.org"
12
+ CONTRACT_HEADER = "X-Mcpcert-Cli-Contract"
13
+
14
+ # Exit-code classes (specs/cli-behavior.md §10).
15
+ EXIT_SUCCESS = 0
16
+ EXIT_REMOTE_FAILURE = 1
17
+ EXIT_USAGE = 2
18
+ EXIT_CREDENTIAL = 3
19
+ EXIT_NETWORK = 4
20
+ EXIT_RATE_LIMITED = 5
21
+ EXIT_VERSION = 6
22
+
23
+ # Stable error code -> exit code (local + remote codes surfaced verbatim).
24
+ ERROR_EXIT: dict[str, int] = {
25
+ # local
26
+ "config_not_found": EXIT_USAGE,
27
+ "config_invalid": EXIT_USAGE,
28
+ "config_conflict": EXIT_USAGE,
29
+ "identity_exists": EXIT_USAGE,
30
+ "tos_not_accepted": EXIT_USAGE,
31
+ "no_update_fields": EXIT_USAGE,
32
+ "unsupported_config_path": EXIT_USAGE,
33
+ "port_unavailable": EXIT_USAGE,
34
+ "claim_token_missing": EXIT_CREDENTIAL,
35
+ "callback_timeout": EXIT_NETWORK,
36
+ "run_lookup_timeout": EXIT_NETWORK,
37
+ "conformance_payload_invalid": EXIT_REMOTE_FAILURE,
38
+ "network_error": EXIT_NETWORK,
39
+ "api_version_unsupported": EXIT_VERSION,
40
+ # remote
41
+ "validation_error": EXIT_REMOTE_FAILURE,
42
+ "tos_not_acknowledged": EXIT_REMOTE_FAILURE,
43
+ "appname_taken": EXIT_REMOTE_FAILURE,
44
+ "appname_reserved": EXIT_REMOTE_FAILURE,
45
+ "dev_registration_not_found": EXIT_REMOTE_FAILURE,
46
+ "dev_registration_expired": EXIT_REMOTE_FAILURE,
47
+ "conformance_run_not_found": EXIT_REMOTE_FAILURE,
48
+ "invalid_claim_token": EXIT_CREDENTIAL,
49
+ "not_owner": EXIT_CREDENTIAL,
50
+ "rate_limited": EXIT_RATE_LIMITED,
51
+ }
52
+
53
+ PROMOTION_PATH = "/dashboard/promote"
54
+
55
+
56
+ def exit_for_error_code(code: str) -> int:
57
+ return ERROR_EXIT.get(code, EXIT_REMOTE_FAILURE)
58
+
59
+
60
+ def promotion_url(api_base_url: str, appname: str) -> str:
61
+ return f"{api_base_url.rstrip('/')}{PROMOTION_PATH}?dev_appname={quote(appname, safe='')}"
mcpcert/credentials.py ADDED
@@ -0,0 +1,79 @@
1
+ """Credential file + .gitignore handling. Mirrors packages/node/src/lib/credentials.ts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from typing import Any
8
+
9
+ from .contract import CREDENTIALS_SCHEMA_VERSION
10
+
11
+
12
+ def identity_key(api_base_url: str, appname: str) -> str:
13
+ return f"{api_base_url}|{appname}"
14
+
15
+
16
+ def default_credentials_path(project_root: str) -> str:
17
+ return os.path.join(project_root, ".mcpcert", "credentials.json")
18
+
19
+
20
+ def _read_file(path: str) -> dict[str, Any]:
21
+ if not os.path.exists(path):
22
+ return {"schema_version": CREDENTIALS_SCHEMA_VERSION, "identities": {}}
23
+ try:
24
+ with open(path, encoding="utf-8") as handle:
25
+ data = json.load(handle)
26
+ data.setdefault("identities", {})
27
+ return data
28
+ except (OSError, ValueError):
29
+ return {"schema_version": CREDENTIALS_SCHEMA_VERSION, "identities": {}}
30
+
31
+
32
+ def get_identity(path: str, api_base_url: str, appname: str) -> dict[str, Any] | None:
33
+ data = _read_file(path)
34
+ return data["identities"].get(identity_key(api_base_url, appname))
35
+
36
+
37
+ def save_identity(path: str, identity: dict[str, Any]) -> None:
38
+ data = _read_file(path)
39
+ data["schema_version"] = CREDENTIALS_SCHEMA_VERSION
40
+ data["identities"][identity_key(identity["api_base_url"], identity["appname"])] = identity
41
+ os.makedirs(os.path.dirname(path), exist_ok=True)
42
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
43
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
44
+ handle.write(json.dumps(data, indent=2) + "\n")
45
+
46
+
47
+ def ensure_gitignore(project_root: str) -> dict[str, Any]:
48
+ if _find_git_dir(project_root) is None:
49
+ return {
50
+ "created": False,
51
+ "warning": ".mcpcert/ holds your claim token (a secret). No Git worktree was found; "
52
+ "ensure it is ignored by any future VCS.",
53
+ }
54
+ gitignore_path = os.path.join(project_root, ".gitignore")
55
+ if os.path.exists(gitignore_path):
56
+ with open(gitignore_path, encoding="utf-8") as handle:
57
+ content = handle.read()
58
+ already = any(line.strip() in (".mcpcert/", ".mcpcert") for line in content.splitlines())
59
+ if already:
60
+ return {"created": False}
61
+ prefix = "" if content.endswith("\n") or content == "" else "\n"
62
+ with open(gitignore_path, "a", encoding="utf-8") as handle:
63
+ handle.write(f"{prefix}.mcpcert/\n")
64
+ return {"created": False}
65
+ with open(gitignore_path, "w", encoding="utf-8") as handle:
66
+ handle.write(".mcpcert/\n")
67
+ return {"created": True}
68
+
69
+
70
+ def _find_git_dir(start: str) -> str | None:
71
+ current = os.path.abspath(start)
72
+ while True:
73
+ candidate = os.path.join(current, ".git")
74
+ if os.path.exists(candidate):
75
+ return candidate
76
+ parent = os.path.dirname(current)
77
+ if parent == current:
78
+ return None
79
+ current = parent
mcpcert/errors.py ADDED
@@ -0,0 +1,23 @@
1
+ """CLI error type carrying a stable error code (specs/cli-behavior.md §10)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from .contract import exit_for_error_code
8
+
9
+
10
+ class CliError(Exception):
11
+ def __init__(
12
+ self,
13
+ code: str,
14
+ message: str,
15
+ *,
16
+ exit_code: int | None = None,
17
+ details: dict[str, Any] | None = None,
18
+ ) -> None:
19
+ super().__init__(message)
20
+ self.code = code
21
+ self.message = message
22
+ self.exit_code = exit_code if exit_code is not None else exit_for_error_code(code)
23
+ self.details = details or {}
mcpcert/output.py ADDED
@@ -0,0 +1,66 @@
1
+ """JSON envelope + human output. Mirrors packages/node/src/lib/output.ts.
2
+
3
+ Color is intentionally omitted so plain-text output matches the TypeScript CLI byte for byte.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import sys
10
+ from typing import Any
11
+
12
+ from .contract import OUTPUT_SCHEMA_VERSION
13
+ from .errors import CliError
14
+
15
+
16
+ class Output:
17
+ def __init__(self, as_json: bool, color: bool = False) -> None:
18
+ self._json = as_json
19
+ # color accepted for contract compatibility but unused (plain text only).
20
+ _ = color
21
+
22
+ def success(
23
+ self,
24
+ command: str,
25
+ api_base_url: str,
26
+ data: dict[str, Any],
27
+ human_lines: list[str],
28
+ warnings: list[dict[str, str]] | None = None,
29
+ ) -> None:
30
+ warnings = warnings or []
31
+ if self._json:
32
+ self._write_json(
33
+ {
34
+ "schema_version": OUTPUT_SCHEMA_VERSION,
35
+ "ok": True,
36
+ "command": command,
37
+ "api_base_url": api_base_url,
38
+ "data": data,
39
+ "warnings": warnings,
40
+ }
41
+ )
42
+ return
43
+ for line in human_lines:
44
+ sys.stdout.write(f"{line}\n")
45
+ for warning in warnings:
46
+ sys.stderr.write(f"warning: {warning['message']} ({warning['code']})\n")
47
+
48
+ def failure(self, command: str, api_base_url: str, err: CliError) -> int:
49
+ if self._json:
50
+ self._write_json(
51
+ {
52
+ "schema_version": OUTPUT_SCHEMA_VERSION,
53
+ "ok": False,
54
+ "command": command,
55
+ "api_base_url": api_base_url,
56
+ "error": {"code": err.code, "message": err.message, "details": err.details},
57
+ "warnings": [],
58
+ }
59
+ )
60
+ else:
61
+ sys.stderr.write(f"error: {err.message} ({err.code})\n")
62
+ return err.exit_code
63
+
64
+ @staticmethod
65
+ def _write_json(payload: Any) -> None:
66
+ sys.stdout.write(json.dumps(payload, indent=2) + "\n")
@@ -0,0 +1,65 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcpcert-cli
3
+ Version: 0.1.0
4
+ Summary: Official mcpcert.org CLI: provision a hosted dev MCP client identity (CIMD) and run an end-to-end OAuth 2.0 + PKCE conformance check, no account required.
5
+ Project-URL: Homepage, https://mcpcert.org
6
+ Project-URL: Issues, https://mcpcert.org/contact
7
+ License-Expression: Apache-2.0
8
+ License-File: LICENSE
9
+ License-File: NOTICE
10
+ Keywords: cimd,cli,mcp,mcpcert,model-context-protocol,oauth,oauth2,pkce
11
+ Requires-Python: >=3.10
12
+ Requires-Dist: tomli>=2.0.1; python_version < '3.11'
13
+ Provides-Extra: dev
14
+ Requires-Dist: pytest>=8.0; extra == 'dev'
15
+ Description-Content-Type: text/markdown
16
+
17
+ # mcpcert (Python)
18
+
19
+ [mcpcert.org](https://mcpcert.org) is a free **registry and conformance service** for **MCP (Model
20
+ Context Protocol) clients**: it hosts your client's metadata document (CIMD) at a stable URL (your
21
+ permanent `client_id`) and runs a conformance sandbox for an end-to-end OAuth 2.0 + PKCE conformance
22
+ check. Development identities are free and need no account; promote one to production within 60 days.
23
+
24
+ This is the **Python** build of the official `mcpcert` CLI (a TypeScript/Node build is also published
25
+ to npm). It installs an executable named `mcpcert` and is a thin HTTP client over the public
26
+ mcpcert.org API.
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ uv tool install mcpcert-cli # or:
32
+ pipx install mcpcert-cli
33
+ ```
34
+
35
+ The PyPI package is `mcpcert-cli`; it installs a command named `mcpcert`. Requires Python >= 3.10.
36
+
37
+ ## Quick start
38
+
39
+ ```bash
40
+ mcpcert init --accept-tos # provision a hosted dev identity (no account)
41
+ mcpcert info # show it (offline)
42
+ mcpcert conformance run # end-to-end OAuth 2.0 + PKCE conformance check
43
+ mcpcert update --redirect-uri http://127.0.0.1:8080/callback
44
+ ```
45
+
46
+ ## Commands
47
+
48
+ | Command | Purpose |
49
+ | --- | --- |
50
+ | `mcpcert init` | Provision an anonymous development identity (CIMD). |
51
+ | `mcpcert update` | Patch mutable dev metadata before expiry (never renews). |
52
+ | `mcpcert info [--check]` | Print the local identity; `--check` reconciles with live server state. |
53
+ | `mcpcert conformance run` | Drive the OAuth/conformance flow end to end. |
54
+ | `mcpcert conformance list` | List conformance runs. |
55
+ | `mcpcert conformance show <runId>` | Show one conformance run. |
56
+
57
+ Run `mcpcert <command> --help` for flags. Add `--json` to any command for machine-readable output.
58
+
59
+ Promotion to a permanent **production** CIMD is done in the web dashboard
60
+ (<https://mcpcert.org/dashboard/promote>) — it requires an account and is **not** a CLI command.
61
+ There is no `promote` or `refresh` command. Full docs: <https://mcpcert.org>.
62
+
63
+ ## License
64
+
65
+ Apache-2.0. See `LICENSE` and `NOTICE` in this package.