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
mcpcert/__init__.py
ADDED
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.")
|