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 ADDED
@@ -0,0 +1,3 @@
1
+ """mcpcert — official CLI for mcpcert.org dev client identities."""
2
+
3
+ __version__ = "0.1.0"
mcpcert/api.py ADDED
@@ -0,0 +1,112 @@
1
+ """HTTP client over the mcpcert.org API. Mirrors packages/node/src/lib/api.ts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import urllib.error
7
+ import urllib.request
8
+ from typing import Any
9
+ from urllib.parse import quote
10
+
11
+ from .contract import CLI_CONTRACT_VERSION, CONTRACT_HEADER
12
+ from .errors import CliError
13
+
14
+
15
+ class ApiClient:
16
+ def __init__(self, base_url: str) -> None:
17
+ self.base_url = base_url
18
+ self._preflighted = False
19
+ self.api_version = "unknown"
20
+
21
+ def version(self) -> dict[str, Any]:
22
+ return self._request("GET", "/api/version")
23
+
24
+ def preflight(self) -> None:
25
+ if self._preflighted:
26
+ return
27
+ meta = self.version()
28
+ self.api_version = str(meta.get("api_version", "unknown"))
29
+ supported = meta.get("supported_cli_contract_versions") or []
30
+ if CLI_CONTRACT_VERSION not in supported:
31
+ raise CliError(
32
+ "api_version_unsupported",
33
+ f"This mcpcert CLI (contract {CLI_CONTRACT_VERSION}) is not supported by {self.base_url}. "
34
+ f"Supported: {', '.join(supported) or 'none'}. Upgrade the CLI.",
35
+ details={"cli_contract_version": CLI_CONTRACT_VERSION, "supported": supported},
36
+ )
37
+ self._preflighted = True
38
+
39
+ def provision(self, body: dict[str, Any]) -> dict[str, Any]:
40
+ self.preflight()
41
+ return self._request("POST", "/api/dev-registrations", body=body)
42
+
43
+ def describe(self, appname: str, token: str) -> dict[str, Any]:
44
+ self.preflight()
45
+ return self._request("GET", f"/api/dev-registrations/{quote(appname, safe='')}", token=token)
46
+
47
+ def update(self, appname: str, token: str, patch: dict[str, Any]) -> dict[str, Any]:
48
+ self.preflight()
49
+ return self._request("PATCH", f"/api/dev-registrations/{quote(appname, safe='')}", token=token, body=patch)
50
+
51
+ def conformance_list(self, appname: str, token: str) -> dict[str, Any]:
52
+ self.preflight()
53
+ return self._request("GET", f"/api/dev-registrations/{quote(appname, safe='')}/conformance", token=token)
54
+
55
+ def conformance_show(self, appname: str, run_id: str, token: str) -> dict[str, Any]:
56
+ self.preflight()
57
+ return self._request(
58
+ "GET",
59
+ f"/api/dev-registrations/{quote(appname, safe='')}/conformance/{quote(run_id, safe='')}",
60
+ token=token,
61
+ )
62
+
63
+ def _request(
64
+ self,
65
+ method: str,
66
+ path: str,
67
+ *,
68
+ token: str | None = None,
69
+ body: dict[str, Any] | None = None,
70
+ ) -> dict[str, Any]:
71
+ url = f"{self.base_url}{path}"
72
+ headers = {"accept": "application/json", CONTRACT_HEADER: CLI_CONTRACT_VERSION}
73
+ if token:
74
+ headers["authorization"] = f"Bearer {token}"
75
+ data = None
76
+ if body is not None:
77
+ data = json.dumps(body).encode("utf-8")
78
+ headers["content-type"] = "application/json"
79
+ request = urllib.request.Request(url, data=data, method=method, headers=headers)
80
+ try:
81
+ with urllib.request.urlopen(request) as response:
82
+ text = response.read().decode("utf-8")
83
+ return _safe_json(text)
84
+ except urllib.error.HTTPError as error:
85
+ raw = error.read().decode("utf-8", errors="replace")
86
+ payload = _safe_json(raw)
87
+ if error.code == 426:
88
+ raise CliError(
89
+ "api_version_unsupported",
90
+ "Server requires a newer mcpcert CLI. Upgrade the CLI.",
91
+ details=payload,
92
+ ) from None
93
+ code = payload.get("error") if isinstance(payload.get("error"), str) else "network_error"
94
+ message = (
95
+ payload.get("error_description")
96
+ if isinstance(payload.get("error_description"), str)
97
+ else f"Request to {path} failed with HTTP {error.code}."
98
+ )
99
+ details = payload.get("details") if isinstance(payload.get("details"), dict) else {}
100
+ raise CliError(code, message, details=details) from None
101
+ except urllib.error.URLError as error:
102
+ raise CliError("network_error", f"Could not reach {url}: {error.reason}") from None
103
+
104
+
105
+ def _safe_json(text: str) -> dict[str, Any]:
106
+ if not text:
107
+ return {}
108
+ try:
109
+ parsed = json.loads(text)
110
+ return parsed if isinstance(parsed, dict) else {"value": parsed}
111
+ except ValueError:
112
+ return {}
mcpcert/args.py ADDED
@@ -0,0 +1,61 @@
1
+ """Minimal argument parser mirroring packages/node/src/lib/args.ts for identical flag semantics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+ BOOLEAN_FLAGS = {"json", "no-color", "help", "version", "check", "no-open", "accept-tos"}
8
+
9
+
10
+ @dataclass
11
+ class ParsedArgs:
12
+ positionals: list[str] = field(default_factory=list)
13
+ options: dict[str, list[str]] = field(default_factory=dict)
14
+ booleans: set[str] = field(default_factory=set)
15
+
16
+
17
+ def parse_args(argv: list[str]) -> ParsedArgs:
18
+ parsed = ParsedArgs()
19
+ i = 0
20
+ while i < len(argv):
21
+ token = argv[i]
22
+ if not token.startswith("--"):
23
+ parsed.positionals.append(token)
24
+ i += 1
25
+ continue
26
+ body = token[2:]
27
+ if "=" in body:
28
+ name, _, value = body.partition("=")
29
+ name = name.strip()
30
+ if name in BOOLEAN_FLAGS:
31
+ parsed.booleans.add(name)
32
+ else:
33
+ parsed.options.setdefault(name, []).append(value)
34
+ i += 1
35
+ continue
36
+ name = body.strip()
37
+ if name in BOOLEAN_FLAGS:
38
+ parsed.booleans.add(name)
39
+ i += 1
40
+ continue
41
+ nxt = argv[i + 1] if i + 1 < len(argv) else None
42
+ if nxt is None or nxt.startswith("--"):
43
+ parsed.options.setdefault(name, []).append("")
44
+ i += 1
45
+ else:
46
+ parsed.options.setdefault(name, []).append(nxt)
47
+ i += 2
48
+ return parsed
49
+
50
+
51
+ def one(args: ParsedArgs, name: str) -> str | None:
52
+ values = args.options.get(name)
53
+ return values[-1] if values else None
54
+
55
+
56
+ def many(args: ParsedArgs, name: str) -> list[str] | None:
57
+ return args.options.get(name)
58
+
59
+
60
+ def boolean(args: ParsedArgs, name: str) -> bool:
61
+ return name in args.booleans
mcpcert/cli.py ADDED
@@ -0,0 +1,89 @@
1
+ """mcpcert CLI entry point. Mirrors packages/node/src/cli.ts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+
8
+ from . import __version__
9
+ from .args import ParsedArgs, boolean, parse_args
10
+ from .commands import conformance_cmd, info_cmd, init_cmd, update_cmd
11
+ from .context import resolve_api_base_url
12
+ from .contract import CLI_CONTRACT_VERSION
13
+ from .errors import CliError
14
+ from .output import Output
15
+
16
+ HELP = """mcpcert - provision and test a hosted dev MCP client identity (CIMD).
17
+
18
+ Usage:
19
+ mcpcert init [--appname <name>] [--client-name <name>] [--application-type <type>]
20
+ [--redirect-uri <uri>]... [--accept-tos]
21
+ mcpcert update [--client-name <name>] [--redirect-uri <uri>]... [--scope <s>] ...
22
+ mcpcert info [--check]
23
+ mcpcert conformance run [--sandbox production|local] [--no-open] [--timeout <s>]
24
+ mcpcert conformance list
25
+ mcpcert conformance show <runId>
26
+
27
+ Global flags:
28
+ --api <url> API base URL (env MCPCERT_API, default https://mcpcert.org)
29
+ --config <path> Explicit config path (.json or .toml)
30
+ --credentials <path> Explicit credentials file path
31
+ --json Emit the JSON envelope
32
+ --no-color Disable color (also NO_COLOR)
33
+ --help Show help
34
+ --version Show version
35
+
36
+ Promotion to production is done in the web dashboard (https://mcpcert.org/dashboard/promote),
37
+ not via the CLI. Dev identities expire after 60 days and are not renewable."""
38
+
39
+ COMMANDS = {
40
+ "init": init_cmd.run,
41
+ "update": update_cmd.run,
42
+ "info": info_cmd.run,
43
+ "conformance": conformance_cmd.run,
44
+ }
45
+
46
+
47
+ def main(argv: list[str] | None = None) -> int:
48
+ # Emit LF (not platform CRLF) and UTF-8 so output is byte-identical to the
49
+ # Node CLI on every platform (specs/cli-behavior.md §12).
50
+ for stream in (sys.stdout, sys.stderr):
51
+ reconfigure = getattr(stream, "reconfigure", None)
52
+ if reconfigure is not None:
53
+ try:
54
+ reconfigure(encoding="utf-8", newline="\n")
55
+ except (ValueError, OSError):
56
+ pass
57
+
58
+ args = parse_args(list(sys.argv[1:] if argv is None else argv))
59
+
60
+ if boolean(args, "version"):
61
+ sys.stdout.write(f"{__version__} (contract {CLI_CONTRACT_VERSION})\n")
62
+ return 0
63
+
64
+ command = args.positionals[0] if args.positionals else None
65
+ if not command or boolean(args, "help"):
66
+ sys.stdout.write(HELP + "\n")
67
+ return 0
68
+
69
+ as_json = boolean(args, "json")
70
+ color = not boolean(args, "no-color") and not os.environ.get("NO_COLOR")
71
+ output = Output(as_json, color)
72
+
73
+ handler = COMMANDS.get(command)
74
+ if handler is None:
75
+ err = CliError("config_invalid", f'Unknown command "{command}". Run `mcpcert --help`.')
76
+ return output.failure(command, resolve_api_base_url(args, None), err)
77
+
78
+ command_args = ParsedArgs(positionals=args.positionals[1:], options=args.options, booleans=args.booleans)
79
+ try:
80
+ return handler(command_args, output)
81
+ except CliError as error:
82
+ return output.failure(command, resolve_api_base_url(args, None), error)
83
+ except Exception as error: # noqa: BLE001 - top-level guard
84
+ wrapped = CliError("unexpected_error", str(error), exit_code=4)
85
+ return output.failure(command, resolve_api_base_url(args, None), wrapped)
86
+
87
+
88
+ if __name__ == "__main__":
89
+ sys.exit(main())
File without changes
@@ -0,0 +1,128 @@
1
+ """`mcpcert conformance run|list|show`. Mirrors packages/node/src/commands/conformance.ts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+
8
+ from ..api import ApiClient
9
+ from ..args import ParsedArgs, boolean, one
10
+ from ..conformance import normalize_run, run_conformance_flow, run_list_row
11
+ from ..config import discover_config
12
+ from ..context import env_flag, resolve_api_base_url
13
+ from ..contract import promotion_url
14
+ from ..credentials import default_credentials_path, get_identity
15
+ from ..errors import CliError
16
+ from ..output import Output
17
+
18
+
19
+ def run(args: ParsedArgs, output: Output) -> int:
20
+ sub = args.positionals[0] if args.positionals else "list"
21
+ cwd = os.getcwd()
22
+ explicit_config = one(args, "config") or os.environ.get("MCPCERT_CONFIG")
23
+ discovered = discover_config(cwd, explicit_config)
24
+ if not discovered.config:
25
+ raise CliError("config_not_found", "No mcpcert development identity is configured here. Run `mcpcert init` first.")
26
+ config = discovered.config
27
+ api_base_url = resolve_api_base_url(args, config)
28
+ creds_path = one(args, "credentials") or os.environ.get("MCPCERT_CREDENTIALS") or default_credentials_path(discovered.project_root)
29
+ identity = get_identity(creds_path, api_base_url, config["appname"])
30
+ if not identity:
31
+ raise CliError("claim_token_missing", "No claim token found for this identity. Conformance access requires the claim token from `mcpcert init`.")
32
+ api = ApiClient(api_base_url)
33
+ promote = promotion_url(api_base_url, config["appname"])
34
+
35
+ if sub == "list":
36
+ listing = api.conformance_list(config["appname"], identity["claim_token"])
37
+ runs = [run_list_row(r) for r in listing.get("runs", [])]
38
+ lines = ["Run ID Started Status Verdict"]
39
+ for r in runs:
40
+ lines.append(f"{(r['run_id'] or '').ljust(35)} {(r['started_at'] or '').ljust(25)} {(r['status'] or '').ljust(10)} {r['verdict']}")
41
+ if not runs:
42
+ lines.append("(no conformance runs yet - run `mcpcert conformance run`)")
43
+ output.success(
44
+ "conformance list",
45
+ api_base_url,
46
+ {"appname": config["appname"], "client_id": config["client_id"], "runs": runs, "api_version": api.api_version},
47
+ lines,
48
+ )
49
+ return 0
50
+
51
+ if sub == "show":
52
+ run_id = args.positionals[1] if len(args.positionals) > 1 else None
53
+ if not run_id:
54
+ raise CliError("config_invalid", "Usage: mcpcert conformance show <runId>")
55
+ normalized = normalize_run(api.conformance_show(config["appname"], run_id, identity["claim_token"]))
56
+ lines = [
57
+ f"Run ID: {normalized['run_id']}",
58
+ f"Started: {normalized['started_at']}",
59
+ f"Status: {normalized['status']}",
60
+ f"Verdict: {normalized['verdict']}",
61
+ ]
62
+ for step in normalized["steps"]:
63
+ detail = f": {step['detail']}" if step.get("detail") else ""
64
+ lines.append(f" - [{step['status']}] {step['title']}{detail}")
65
+ output.success("conformance show", api_base_url, {"run": normalized, "api_version": api.api_version}, lines)
66
+ return 1 if normalized["verdict"] in ("fail", "error") else 0
67
+
68
+ if sub == "run":
69
+ timeout_raw = one(args, "timeout") or os.environ.get("MCPCERT_TIMEOUT") or "180"
70
+ try:
71
+ timeout_ms = int(float(timeout_raw) * 1000)
72
+ except ValueError:
73
+ timeout_ms = 180000
74
+ profile_raw = one(args, "sandbox") or os.environ.get("MCPCERT_SANDBOX") or "production"
75
+ profile = "local" if profile_raw in ("local", "dev") else "production"
76
+ no_open = boolean(args, "no-open") or env_flag("MCPCERT_NO_OPEN")
77
+
78
+ result = run_conformance_flow(
79
+ config=config,
80
+ claim_token=identity["claim_token"],
81
+ api_client=api,
82
+ api_base_url=api_base_url,
83
+ profile=profile,
84
+ dev_origin=one(args, "dev-origin") or os.environ.get("MCPCERT_DEV_ORIGIN"),
85
+ redirect_uri_flag=one(args, "redirect-uri"),
86
+ no_open=no_open,
87
+ timeout_ms=timeout_ms,
88
+ emit=lambda line: sys.stderr.write(f"{line}\n"),
89
+ )
90
+
91
+ run_obj = result["run"]
92
+ warnings: list[dict[str, str]] = []
93
+ data = {
94
+ "appname": config["appname"],
95
+ "client_id": config["client_id"],
96
+ "started_at": result["started_at"],
97
+ "status": run_obj["status"] if run_obj else "unknown",
98
+ "verdict": run_obj["verdict"] if run_obj else "incomplete",
99
+ "summary": run_obj["summary"] if run_obj else {"total": 0, "pass": 0, "fail": 0, "warn": 0, "skip": 0},
100
+ "promotion_url": promote,
101
+ "api_version": api.api_version,
102
+ }
103
+ if run_obj:
104
+ data["run_id"] = run_obj["run_id"]
105
+ if result["run_lookup_timed_out"]:
106
+ warnings.append(
107
+ {
108
+ "code": "run_lookup_pending",
109
+ "message": "The OAuth flow completed but the conformance run was not retrievable before timeout. Try `mcpcert conformance list` shortly.",
110
+ }
111
+ )
112
+ output.success(
113
+ "conformance run",
114
+ api_base_url,
115
+ data,
116
+ [
117
+ "Conformance run started",
118
+ f"Client ID: {config['client_id']}",
119
+ f"Run: {run_obj['run_id']}" if run_obj else "Run: (pending - see `mcpcert conformance list`)",
120
+ f"Verdict: {run_obj['verdict'] if run_obj else 'incomplete'}",
121
+ f"Promote: {promote}",
122
+ ],
123
+ warnings,
124
+ )
125
+ verdict = run_obj["verdict"] if run_obj else "incomplete"
126
+ return 1 if verdict in ("fail", "error") else 0
127
+
128
+ raise CliError("config_invalid", f'Unknown conformance subcommand "{sub}". Use run, list, or show.')
@@ -0,0 +1,91 @@
1
+ """`mcpcert info`. Mirrors packages/node/src/commands/info.ts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+
8
+ from ..api import ApiClient
9
+ from ..args import ParsedArgs, boolean, one
10
+ from ..config import discover_config
11
+ from ..context import resolve_api_base_url
12
+ from ..contract import promotion_url
13
+ from ..credentials import default_credentials_path, get_identity
14
+ from ..errors import CliError
15
+ from ..output import Output
16
+
17
+
18
+ def run(args: ParsedArgs, output: Output) -> int:
19
+ cwd = os.getcwd()
20
+ explicit_config = one(args, "config") or os.environ.get("MCPCERT_CONFIG")
21
+ discovered = discover_config(cwd, explicit_config)
22
+ if not discovered.config:
23
+ raise CliError("config_not_found", "No mcpcert development identity is configured here. Run `mcpcert init` first.")
24
+ config = discovered.config
25
+ api_base_url = resolve_api_base_url(args, config)
26
+ promote = promotion_url(api_base_url, config["appname"])
27
+ creds_path = one(args, "credentials") or os.environ.get("MCPCERT_CREDENTIALS") or default_credentials_path(discovered.project_root)
28
+
29
+ if not boolean(args, "check"):
30
+ output.success(
31
+ "info",
32
+ api_base_url,
33
+ {
34
+ "appname": config["appname"],
35
+ "environment": config["environment"],
36
+ "application_type": config["application_type"],
37
+ "client_name": config["client_name"],
38
+ "client_id": config["client_id"],
39
+ "document_urls": config["document_urls"],
40
+ "expires_at": config["expires_at"],
41
+ "promotion_url": promote,
42
+ "config_path": discovered.config_path,
43
+ "credentials_path": creds_path,
44
+ "source": "cache",
45
+ },
46
+ _human_lines(config["appname"], config["client_id"], config["expires_at"], promote, "cache"),
47
+ )
48
+ return 0
49
+
50
+ identity = get_identity(creds_path, api_base_url, config["appname"])
51
+ if not identity:
52
+ raise CliError("claim_token_missing", "No claim token found for this identity; cannot fetch live state.")
53
+ api = ApiClient(api_base_url)
54
+ live = api.describe(config["appname"], identity["claim_token"])
55
+
56
+ warnings: list[dict[str, str]] = []
57
+ if live.get("client_name") != config["client_name"]:
58
+ warnings.append({"code": "config_drift", "message": f'client_name differs: local "{config["client_name"]}" vs live "{live.get("client_name")}".'})
59
+ if json.dumps(live.get("redirect_uris")) != json.dumps(config["redirect_uris"]):
60
+ warnings.append({"code": "config_drift", "message": "redirect_uris differ between local config and live state."})
61
+
62
+ output.success(
63
+ "info",
64
+ api_base_url,
65
+ {
66
+ "appname": live["appname"],
67
+ "environment": live["environment"],
68
+ "application_type": live["application_type"],
69
+ "client_name": live["client_name"],
70
+ "client_id": live["client_id"],
71
+ "document_urls": live["document_urls"],
72
+ "expires_at": live["expires_at"],
73
+ "promotion_url": promote,
74
+ "config_path": discovered.config_path,
75
+ "credentials_path": creds_path,
76
+ "source": "live",
77
+ },
78
+ _human_lines(live["appname"], live["client_id"], live["expires_at"], promote, "live"),
79
+ warnings,
80
+ )
81
+ return 0
82
+
83
+
84
+ def _human_lines(appname: str, client_id: str, expires_at: str, promote: str, source: str) -> list[str]:
85
+ return [
86
+ f"Development identity ({source})",
87
+ f"Appname: {appname}",
88
+ f"Client ID: {client_id}",
89
+ f"Expires: {expires_at}",
90
+ f"Promote: {promote}",
91
+ ]
@@ -0,0 +1,148 @@
1
+ """`mcpcert init`. Mirrors packages/node/src/commands/init.ts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ from datetime import datetime, timezone
8
+
9
+ from ..api import ApiClient
10
+ from ..args import ParsedArgs, boolean, many, one
11
+ from ..config import build_config, choose_write_target, discover_config, resolve_metadata, write_config
12
+ from ..context import is_non_interactive, resolve_api_base_url
13
+ from ..contract import promotion_url
14
+ from ..credentials import default_credentials_path, ensure_gitignore, get_identity, save_identity
15
+ from ..errors import CliError
16
+ from ..output import Output
17
+
18
+ APP_TYPES = ("native", "web", "spa", "mobile")
19
+
20
+
21
+ def run(args: ParsedArgs, output: Output) -> int:
22
+ cwd = os.getcwd()
23
+ explicit_config = one(args, "config") or os.environ.get("MCPCERT_CONFIG")
24
+ discovered = discover_config(cwd, explicit_config)
25
+
26
+ if discovered.config:
27
+ creds_path = one(args, "credentials") or os.environ.get("MCPCERT_CREDENTIALS") or default_credentials_path(discovered.project_root)
28
+ existing = get_identity(creds_path, discovered.config["api_base_url"], discovered.config["appname"])
29
+ if existing:
30
+ raise CliError(
31
+ "identity_exists",
32
+ "A mcpcert development identity is already configured for this project. Use `mcpcert update` or promote it in the dashboard.",
33
+ )
34
+ raise CliError(
35
+ "claim_token_missing",
36
+ "Project config exists but its claim token is missing and cannot be recovered. Remove the stale config to provision a new identity, or restore the credentials file.",
37
+ )
38
+
39
+ api_base_url = resolve_api_base_url(args, None)
40
+ _acknowledge_tos(args)
41
+
42
+ app_type = one(args, "application-type") or "native"
43
+ if app_type not in APP_TYPES:
44
+ raise CliError("config_invalid", f"--application-type must be one of: {', '.join(APP_TYPES)}.")
45
+ redirect_uris = many(args, "redirect-uri") or ["http://127.0.0.1:8080/callback"]
46
+ explicit_appname = one(args, "appname")
47
+ meta = resolve_metadata(cwd, one(args, "client-name"), one(args, "project-name"), None)
48
+
49
+ body: dict[str, object] = {
50
+ "application_type": app_type,
51
+ "client_name": meta.client_name,
52
+ "redirect_uris": redirect_uris,
53
+ "tos_acknowledged": True,
54
+ }
55
+ if explicit_appname:
56
+ body["appname"] = explicit_appname
57
+ else:
58
+ body["project_slug"] = meta.project_slug
59
+ for flag in ("client-uri", "logo-uri", "tos-uri", "policy-uri", "scope"):
60
+ value = one(args, flag)
61
+ if value:
62
+ body[flag.replace("-", "_")] = value
63
+ contacts = many(args, "contact")
64
+ if contacts:
65
+ body["contacts"] = contacts
66
+
67
+ api = ApiClient(api_base_url)
68
+ provisioned = api.provision(body)
69
+
70
+ target_path, target_fmt = choose_write_target(discovered.project_root, explicit_config)
71
+ config = build_config(
72
+ appname=provisioned["appname"],
73
+ application_type=app_type,
74
+ client_name=meta.client_name,
75
+ project_slug=meta.project_slug,
76
+ redirect_uris=redirect_uris,
77
+ client_id=provisioned["client_id"],
78
+ document_urls=provisioned["document_urls"],
79
+ expires_at=provisioned["expires_at"],
80
+ api_base_url=api_base_url,
81
+ )
82
+ write_config(target_path, target_fmt, config)
83
+
84
+ creds_path = one(args, "credentials") or os.environ.get("MCPCERT_CREDENTIALS") or default_credentials_path(discovered.project_root)
85
+ save_identity(
86
+ creds_path,
87
+ {
88
+ "api_base_url": api_base_url,
89
+ "appname": provisioned["appname"],
90
+ "environment": "development",
91
+ "claim_token": provisioned["claim_token"],
92
+ "client_id": provisioned["client_id"],
93
+ "created_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.") + f"{datetime.now(timezone.utc).microsecond // 1000:03d}Z",
94
+ "expires_at": provisioned["expires_at"],
95
+ },
96
+ )
97
+
98
+ warnings: list[dict[str, str]] = []
99
+ gi = ensure_gitignore(discovered.project_root)
100
+ if gi.get("warning"):
101
+ warnings.append({"code": "gitignore_skipped", "message": gi["warning"]})
102
+
103
+ promote = promotion_url(api_base_url, provisioned["appname"])
104
+ output.success(
105
+ "init",
106
+ api_base_url,
107
+ {
108
+ "appname": provisioned["appname"],
109
+ "environment": "development",
110
+ "application_type": app_type,
111
+ "client_name": meta.client_name,
112
+ "client_id": provisioned["client_id"],
113
+ "document_urls": provisioned["document_urls"],
114
+ "expires_at": provisioned["expires_at"],
115
+ "promotion_url": promote,
116
+ "config_path": target_path,
117
+ "credentials_path": creds_path,
118
+ "api_version": api.api_version,
119
+ },
120
+ [
121
+ "Development identity created",
122
+ f"Appname: {provisioned['appname']}",
123
+ f"Client ID: {provisioned['client_id']}",
124
+ f"CIMD: {provisioned['document_urls']['cimd']}",
125
+ f"Expires: {provisioned['expires_at']}",
126
+ f"Promote: {promote}",
127
+ f"Config: {target_path}",
128
+ f"Credentials: {creds_path}",
129
+ ],
130
+ warnings,
131
+ )
132
+ return 0
133
+
134
+
135
+ def _acknowledge_tos(args: ParsedArgs) -> None:
136
+ if boolean(args, "accept-tos"):
137
+ return
138
+ if is_non_interactive():
139
+ raise CliError(
140
+ "tos_not_accepted",
141
+ "Terms of Service must be accepted to provision an identity. Re-run with --accept-tos in non-interactive environments.",
142
+ )
143
+ sys.stderr.write("Review the mcpcert.org Terms of Service: https://mcpcert.org/terms\n")
144
+ sys.stderr.write("Do you accept the Terms of Service? [y/N] ")
145
+ sys.stderr.flush()
146
+ answer = sys.stdin.readline().strip().lower()
147
+ if answer not in ("y", "yes"):
148
+ raise CliError("tos_not_accepted", "Terms of Service were not accepted.")