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/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.
|