testmcpy-oauth-probe 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.
@@ -0,0 +1,44 @@
1
+ """Typed public API for the headless OAuth/MCP probe."""
2
+
3
+ from testmcpy_oauth_probe.config import (
4
+ ConfigError,
5
+ load_manifest,
6
+ loads_manifest,
7
+ manifest_json_schema,
8
+ report_json_schema,
9
+ )
10
+ from testmcpy_oauth_probe.models import (
11
+ CONFIG_SCHEMA,
12
+ REPORT_SCHEMA,
13
+ AuthFlow,
14
+ CapabilityPolicy,
15
+ CheckResult,
16
+ CheckStatus,
17
+ Manifest,
18
+ ProbeReport,
19
+ RunReport,
20
+ TargetConfig,
21
+ )
22
+ from testmcpy_oauth_probe.runner import ProbeRunner, run_manifest
23
+ from testmcpy_oauth_probe.transport import HttpTransport
24
+
25
+ __all__ = [
26
+ "CONFIG_SCHEMA",
27
+ "REPORT_SCHEMA",
28
+ "AuthFlow",
29
+ "CapabilityPolicy",
30
+ "CheckResult",
31
+ "CheckStatus",
32
+ "ConfigError",
33
+ "Manifest",
34
+ "HttpTransport",
35
+ "ProbeReport",
36
+ "ProbeRunner",
37
+ "RunReport",
38
+ "TargetConfig",
39
+ "load_manifest",
40
+ "loads_manifest",
41
+ "manifest_json_schema",
42
+ "report_json_schema",
43
+ "run_manifest",
44
+ ]
@@ -0,0 +1,3 @@
1
+ from testmcpy_oauth_probe.cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,152 @@
1
+ """Minimal argparse CLI, independent of testmcpy's UI/LLM dependencies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import sys
8
+ from collections.abc import Sequence
9
+ from pathlib import Path
10
+
11
+ from testmcpy_oauth_probe.config import (
12
+ ConfigError,
13
+ dump_manifest_schema,
14
+ dump_report_schema,
15
+ load_manifest,
16
+ loads_manifest,
17
+ )
18
+ from testmcpy_oauth_probe.models import CONFIG_SCHEMA, Correlation
19
+ from testmcpy_oauth_probe.reporters import to_human, to_json, to_jsonl, to_junit
20
+ from testmcpy_oauth_probe.runner import ProbeRunner
21
+
22
+ # `--config` stays first so it remains the name in `--help` output and in every
23
+ # existing pipeline; `--manifest` is an accepted alias. argparse stores both
24
+ # under `dest="config"`.
25
+ _MANIFEST_FLAGS = ("--config", "--manifest")
26
+ _MANIFEST_KWARGS = {
27
+ "dest": "config",
28
+ "metavar": "PATH",
29
+ "help": "Path to the versioned probe manifest (YAML or JSON)",
30
+ }
31
+
32
+
33
+ def build_parser() -> argparse.ArgumentParser:
34
+ parser = argparse.ArgumentParser(
35
+ prog="testmcpy-oauth",
36
+ description="Vendor-neutral OAuth/MCP interoperability probe (not formal certification)",
37
+ )
38
+ subparsers = parser.add_subparsers(dest="command", required=True)
39
+ validate = subparsers.add_parser("validate", help="Validate a manifest without network access")
40
+ # Everything else — the docs, the schema name, the Auth Smoke page, this
41
+ # tool's own help text — calls the file a manifest, so `--manifest` is the
42
+ # flag people reach for first. Accept both rather than make them guess.
43
+ validate.add_argument(*_MANIFEST_FLAGS, required=True, **_MANIFEST_KWARGS)
44
+ schema = subparsers.add_parser("schema", help="Print a versioned JSON Schema")
45
+ schema.add_argument("--kind", choices=("manifest", "report"), default="manifest")
46
+ schema.set_defaults(command="schema")
47
+ check = subparsers.add_parser("check", help="Run configured targets headlessly")
48
+ check.add_argument(*_MANIFEST_FLAGS, required=True, **_MANIFEST_KWARGS)
49
+ check.add_argument("--target", action="append", dest="targets")
50
+ check.add_argument("--profile")
51
+ check.add_argument("--format", choices=("human", "json", "jsonl"), default="human")
52
+ check.add_argument("--output", default="-")
53
+ check.add_argument("--junit")
54
+ check.add_argument("--run-id")
55
+ check.add_argument("--service", help="Report label only: recorded in correlation.service.")
56
+ check.add_argument("--region", help="Report label only: recorded in correlation.region.")
57
+ check.add_argument(
58
+ "--revision",
59
+ help=(
60
+ "Report label only: recorded in correlation.revision and never "
61
+ "compared against the deployed revision. This is not a revision assertion."
62
+ ),
63
+ )
64
+ check.add_argument(
65
+ "--deployment-id", help="Report label only: recorded in correlation.deployment_id."
66
+ )
67
+ discover = subparsers.add_parser("discover", help="Run discovery without credentials")
68
+ discover.add_argument("--url", required=True)
69
+ discover.add_argument("--format", choices=("human", "json", "jsonl"), default="human")
70
+ return parser
71
+
72
+
73
+ def _write(path: str, content: str) -> None:
74
+ if path == "-":
75
+ sys.stdout.write(content)
76
+ else:
77
+ Path(path).write_text(content, encoding="utf-8")
78
+
79
+
80
+ def _render(report_format: str, report: object) -> str:
81
+ from testmcpy_oauth_probe.models import RunReport
82
+
83
+ assert isinstance(report, RunReport)
84
+ if report_format == "json":
85
+ return to_json(report)
86
+ if report_format == "jsonl":
87
+ return to_jsonl(report)
88
+ return to_human(report)
89
+
90
+
91
+ async def _run(args: argparse.Namespace) -> int:
92
+ if args.command == "discover":
93
+ manifest = loads_manifest(
94
+ f"""schema: {CONFIG_SCHEMA}
95
+ targets:
96
+ discovery:
97
+ mcp_url: {args.url!r}
98
+ oauth:
99
+ flow: none
100
+ # `discover` is advertised as read-only reconnaissance. error_probe
101
+ # defaults to true and now applies to every flow, so opt out explicitly
102
+ # rather than POST an unsupported grant to someone's token endpoint.
103
+ error_probe: false
104
+ """
105
+ )
106
+ report = await ProbeRunner().run_manifest(manifest)
107
+ _write("-", _render(args.format, report))
108
+ return report.exit_code
109
+ manifest = load_manifest(args.config)
110
+ report = await ProbeRunner().run_manifest(
111
+ manifest,
112
+ target_ids=args.targets,
113
+ profile=args.profile,
114
+ run_id=args.run_id,
115
+ correlation_override=Correlation(
116
+ service=args.service,
117
+ region=args.region,
118
+ revision=args.revision,
119
+ deployment_id=args.deployment_id,
120
+ ),
121
+ )
122
+ _write(args.output, _render(args.format, report))
123
+ if args.junit:
124
+ _write(args.junit, to_junit(report))
125
+ return report.exit_code
126
+
127
+
128
+ def main(argv: Sequence[str] | None = None) -> int:
129
+ parser = build_parser()
130
+ args = parser.parse_args(argv)
131
+ try:
132
+ if args.command == "schema":
133
+ print(dump_report_schema() if args.kind == "report" else dump_manifest_schema())
134
+ return 0
135
+ if args.command == "validate":
136
+ manifest = load_manifest(args.config)
137
+ print(
138
+ f"valid {manifest.schema} manifest: {len(manifest.targets)} target(s), "
139
+ f"{len(manifest.profiles)} profile(s)"
140
+ )
141
+ return 0
142
+ return asyncio.run(_run(args))
143
+ except (ConfigError, ValueError) as exc:
144
+ print(f"configuration error: {exc}", file=sys.stderr)
145
+ return 2
146
+ except KeyboardInterrupt:
147
+ print("interrupted", file=sys.stderr)
148
+ return 2
149
+
150
+
151
+ if __name__ == "__main__":
152
+ raise SystemExit(main())
@@ -0,0 +1,426 @@
1
+ """Strict loader for the versioned OAuth probe manifest."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ import os
8
+ import re
9
+ from collections.abc import Mapping
10
+ from importlib.resources import files
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import yaml
15
+
16
+ from testmcpy_oauth_probe.models import (
17
+ CONFIG_SCHEMA,
18
+ SUPPORTED_SPEC_PROFILES,
19
+ AuthFlow,
20
+ CapabilityPolicy,
21
+ ClientAuthMethod,
22
+ Correlation,
23
+ Expectations,
24
+ Manifest,
25
+ OAuthConfig,
26
+ RunProfile,
27
+ SecretRef,
28
+ TargetConfig,
29
+ ValueRef,
30
+ )
31
+
32
+
33
+ class ConfigError(ValueError):
34
+ pass
35
+
36
+
37
+ _ENV_RE = re.compile(r"^\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-(.*))?\}$")
38
+
39
+
40
+ def _mapping(value: Any, path: str) -> dict[str, Any]:
41
+ if value is None:
42
+ return {}
43
+ if not isinstance(value, Mapping):
44
+ raise ConfigError(f"{path} must be an object")
45
+ return {str(key): item for key, item in value.items()}
46
+
47
+
48
+ def _only(data: Mapping[str, Any], allowed: set[str], path: str) -> None:
49
+ unknown = sorted(set(data) - allowed)
50
+ if unknown:
51
+ raise ConfigError(f"{path} contains unknown field(s): {', '.join(unknown)}")
52
+
53
+
54
+ def _expand_env(value: str, path: str) -> str:
55
+ """Resolve a whole-scalar `${NAME}` / `${NAME:-default}` reference."""
56
+ match = _ENV_RE.match(value)
57
+ if match is None:
58
+ return value
59
+ env_name, default = match.groups()
60
+ resolved = os.environ.get(env_name, default)
61
+ if resolved is None:
62
+ raise ConfigError(f"{path} references unset environment variable {env_name!r}")
63
+ return resolved
64
+
65
+
66
+ def _string(value: Any, path: str, *, required: bool = False) -> str | None:
67
+ if value is None and not required:
68
+ return None
69
+ if not isinstance(value, str) or not value:
70
+ raise ConfigError(f"{path} must be a non-empty string")
71
+ return _expand_env(value, path)
72
+
73
+
74
+ def _strings(value: Any, path: str) -> tuple[str, ...]:
75
+ if value is None:
76
+ return ()
77
+ if not isinstance(value, list) or not all(isinstance(item, str) and item for item in value):
78
+ raise ConfigError(f"{path} must be an array of non-empty strings")
79
+ # Array elements expand exactly like scalars. Without this, `mcp_url:
80
+ # ${SMOKE_MCP_URL}` resolved while `issuers: ["${SMOKE_ORIGIN}"]` was
81
+ # compared literally, which forced a manifest generated per run for any
82
+ # ephemeral target. Duplicates are judged after expansion, since two
83
+ # references that resolve to the same value are the same expectation.
84
+ expanded = tuple(_expand_env(item, f"{path}[{index}]") for index, item in enumerate(value))
85
+ if any(not item for item in expanded):
86
+ raise ConfigError(f"{path} must not contain empty values after expansion")
87
+ if len(set(expanded)) != len(expanded):
88
+ raise ConfigError(f"{path} must not contain duplicates")
89
+ return expanded
90
+
91
+
92
+ def _secret_ref(value: Any, path: str) -> SecretRef | None:
93
+ if value is None:
94
+ return None
95
+ data = _mapping(value, path)
96
+ _only(data, {"env"}, path)
97
+ env_name = _string(data.get("env"), f"{path}.env", required=True)
98
+ assert env_name is not None
99
+ if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", env_name):
100
+ raise ConfigError(f"{path}.env must be an environment variable name")
101
+ return SecretRef(env=env_name)
102
+
103
+
104
+ def _value_ref(value: Any, path: str) -> ValueRef | None:
105
+ if value is None:
106
+ return None
107
+ if isinstance(value, str):
108
+ return ValueRef(value=value)
109
+ data = _mapping(value, path)
110
+ _only(data, {"env"}, path)
111
+ env_name = _string(data.get("env"), f"{path}.env", required=True)
112
+ assert env_name is not None
113
+ if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", env_name):
114
+ raise ConfigError(f"{path}.env must be an environment variable name")
115
+ return ValueRef(env=env_name)
116
+
117
+
118
+ def _policies(value: Any, path: str) -> dict[str, CapabilityPolicy]:
119
+ data = _mapping(value, path)
120
+ result: dict[str, CapabilityPolicy] = {}
121
+ for name, raw_policy in data.items():
122
+ try:
123
+ result[name] = CapabilityPolicy(raw_policy)
124
+ except ValueError as exc:
125
+ choices = ", ".join(policy.value for policy in CapabilityPolicy)
126
+ raise ConfigError(f"{path}.{name} must be one of: {choices}") from exc
127
+ return result
128
+
129
+
130
+ def _oauth(value: Any, path: str) -> OAuthConfig:
131
+ data = _mapping(value, path)
132
+ _only(
133
+ data,
134
+ {
135
+ "flow",
136
+ "access_token",
137
+ "refresh_token",
138
+ "refresh_token_disposable",
139
+ "authorization_code",
140
+ "pkce_verifier",
141
+ "client_id",
142
+ "client_secret",
143
+ "client_auth_method",
144
+ "scopes",
145
+ "resource",
146
+ "audience",
147
+ "token_endpoint",
148
+ "redirect_uri",
149
+ "error_probe",
150
+ },
151
+ path,
152
+ )
153
+ try:
154
+ flow = AuthFlow(data.get("flow", "none"))
155
+ auth_method = ClientAuthMethod(data.get("client_auth_method", "none"))
156
+ except ValueError as exc:
157
+ raise ConfigError(f"{path} contains an unsupported flow or client auth method") from exc
158
+ error_probe = data.get("error_probe", True)
159
+ if not isinstance(error_probe, bool):
160
+ raise ConfigError(f"{path}.error_probe must be a boolean")
161
+ disposable = data.get("refresh_token_disposable", False)
162
+ if not isinstance(disposable, bool):
163
+ raise ConfigError(f"{path}.refresh_token_disposable must be a boolean")
164
+ config = OAuthConfig(
165
+ flow=flow,
166
+ access_token=_secret_ref(data.get("access_token"), f"{path}.access_token"),
167
+ refresh_token=_secret_ref(data.get("refresh_token"), f"{path}.refresh_token"),
168
+ refresh_token_disposable=disposable,
169
+ authorization_code=_secret_ref(
170
+ data.get("authorization_code"), f"{path}.authorization_code"
171
+ ),
172
+ pkce_verifier=_secret_ref(data.get("pkce_verifier"), f"{path}.pkce_verifier"),
173
+ client_id=_value_ref(data.get("client_id"), f"{path}.client_id"),
174
+ client_secret=_secret_ref(data.get("client_secret"), f"{path}.client_secret"),
175
+ client_auth_method=auth_method,
176
+ scopes=_strings(data.get("scopes"), f"{path}.scopes"),
177
+ resource=_string(data.get("resource"), f"{path}.resource"),
178
+ audience=_string(data.get("audience"), f"{path}.audience"),
179
+ token_endpoint=_string(data.get("token_endpoint"), f"{path}.token_endpoint"),
180
+ redirect_uri=_string(data.get("redirect_uri"), f"{path}.redirect_uri"),
181
+ error_probe=error_probe,
182
+ )
183
+ required: dict[AuthFlow, tuple[tuple[str, Any], ...]] = {
184
+ AuthFlow.BEARER: (("access_token", config.access_token),),
185
+ AuthFlow.REFRESH_TOKEN: (("refresh_token", config.refresh_token),),
186
+ AuthFlow.AUTHORIZATION_CODE: (
187
+ ("authorization_code", config.authorization_code),
188
+ ("pkce_verifier", config.pkce_verifier),
189
+ ("redirect_uri", config.redirect_uri),
190
+ ),
191
+ AuthFlow.CLIENT_CREDENTIALS: (("client_id", config.client_id),),
192
+ }
193
+ missing = [name for name, item in required.get(flow, ()) if item is None]
194
+ if missing:
195
+ raise ConfigError(f"{path} flow {flow.value!r} requires: {', '.join(missing)}")
196
+ if auth_method is not ClientAuthMethod.NONE and config.client_id is None:
197
+ raise ConfigError(f"{path}.client_id is required for {auth_method.value}")
198
+ if auth_method is not ClientAuthMethod.NONE and config.client_secret is None:
199
+ raise ConfigError(f"{path}.client_secret is required for {auth_method.value}")
200
+ if flow is AuthFlow.CLIENT_CREDENTIALS and auth_method is ClientAuthMethod.NONE:
201
+ raise ConfigError(f"{path} client_credentials requires confidential client authentication")
202
+ return config
203
+
204
+
205
+ def _expectations(value: Any, path: str) -> Expectations:
206
+ data = _mapping(value, path)
207
+ _only(
208
+ data,
209
+ {
210
+ "issuers",
211
+ "token_issuers",
212
+ "resources",
213
+ "audiences",
214
+ "scopes",
215
+ "grants",
216
+ "auth_methods",
217
+ "endpoints",
218
+ "capabilities",
219
+ "unauthorized_status",
220
+ "initialize_status",
221
+ "initialized_statuses",
222
+ "tools_list_status",
223
+ "min_tools",
224
+ },
225
+ path,
226
+ )
227
+ status_fields: dict[str, int] = {}
228
+ for name, default in (
229
+ ("unauthorized_status", 401),
230
+ ("initialize_status", 200),
231
+ ("tools_list_status", 200),
232
+ ):
233
+ raw = data.get(name, default)
234
+ if not isinstance(raw, int) or isinstance(raw, bool) or not 100 <= raw <= 599:
235
+ raise ConfigError(f"{path}.{name} must be an HTTP status between 100 and 599")
236
+ status_fields[name] = raw
237
+ min_tools = data.get("min_tools", 0)
238
+ if not isinstance(min_tools, int) or isinstance(min_tools, bool) or min_tools < 0:
239
+ raise ConfigError(f"{path}.min_tools must be a non-negative integer")
240
+ initialized_statuses = data.get("initialized_statuses", [200, 202])
241
+ if (
242
+ not isinstance(initialized_statuses, list)
243
+ or not initialized_statuses
244
+ or not all(
245
+ isinstance(item, int) and not isinstance(item, bool) and 100 <= item <= 599
246
+ for item in initialized_statuses
247
+ )
248
+ ):
249
+ raise ConfigError(f"{path}.initialized_statuses must be a non-empty array of HTTP statuses")
250
+ if len(set(initialized_statuses)) != len(initialized_statuses):
251
+ raise ConfigError(f"{path}.initialized_statuses must not contain duplicates")
252
+ return Expectations(
253
+ issuers=_strings(data.get("issuers"), f"{path}.issuers"),
254
+ token_issuers=_strings(data.get("token_issuers"), f"{path}.token_issuers"),
255
+ resources=_strings(data.get("resources"), f"{path}.resources"),
256
+ audiences=_strings(data.get("audiences"), f"{path}.audiences"),
257
+ scopes=_strings(data.get("scopes"), f"{path}.scopes"),
258
+ grants=_policies(data.get("grants"), f"{path}.grants"),
259
+ auth_methods=_policies(data.get("auth_methods"), f"{path}.auth_methods"),
260
+ endpoints=_policies(data.get("endpoints"), f"{path}.endpoints"),
261
+ capabilities=_policies(data.get("capabilities"), f"{path}.capabilities"),
262
+ unauthorized_status=status_fields["unauthorized_status"],
263
+ initialize_status=status_fields["initialize_status"],
264
+ initialized_statuses=tuple(initialized_statuses),
265
+ tools_list_status=status_fields["tools_list_status"],
266
+ min_tools=min_tools,
267
+ )
268
+
269
+
270
+ def _correlation(value: Any, path: str) -> Correlation:
271
+ data = _mapping(value, path)
272
+ _only(data, {"service", "region", "revision", "deployment_id"}, path)
273
+ return Correlation(
274
+ service=_string(data.get("service"), f"{path}.service"),
275
+ region=_string(data.get("region"), f"{path}.region"),
276
+ revision=_string(data.get("revision"), f"{path}.revision"),
277
+ deployment_id=_string(data.get("deployment_id"), f"{path}.deployment_id"),
278
+ )
279
+
280
+
281
+ def _target(target_id: str, value: Any, defaults: Mapping[str, Any]) -> TargetConfig:
282
+ if not target_id:
283
+ raise ConfigError("target IDs must be non-empty strings")
284
+ path = f"targets.{target_id}"
285
+ data = _mapping(value, path)
286
+ _only(
287
+ data,
288
+ {
289
+ "mcp_url",
290
+ "spec_profile",
291
+ "correlation",
292
+ "oauth",
293
+ "expectations",
294
+ "timeout_seconds",
295
+ "max_response_bytes",
296
+ "transient_retries",
297
+ "allow_http_loopback",
298
+ "allow_private_network",
299
+ },
300
+ path,
301
+ )
302
+ merged = {**defaults, **data}
303
+ mcp_url = _string(merged.get("mcp_url"), f"{path}.mcp_url", required=True)
304
+ spec_profile = _string(merged.get("spec_profile", "mcp-2025-06-18"), f"{path}.spec_profile")
305
+ assert mcp_url is not None and spec_profile is not None
306
+ if spec_profile not in SUPPORTED_SPEC_PROFILES:
307
+ raise ConfigError(
308
+ f"{path}.spec_profile must be one of: {', '.join(SUPPORTED_SPEC_PROFILES)}"
309
+ )
310
+ timeout = merged.get("timeout_seconds", 20.0)
311
+ if (
312
+ not isinstance(timeout, (int, float))
313
+ or isinstance(timeout, bool)
314
+ or not math.isfinite(timeout)
315
+ or timeout <= 0
316
+ ):
317
+ raise ConfigError(f"{path}.timeout_seconds must be positive")
318
+ max_bytes = merged.get("max_response_bytes", 1_048_576)
319
+ retries = merged.get("transient_retries", 1)
320
+ if not isinstance(max_bytes, int) or isinstance(max_bytes, bool) or max_bytes < 1024:
321
+ raise ConfigError(f"{path}.max_response_bytes must be an integer >= 1024")
322
+ if not isinstance(retries, int) or isinstance(retries, bool) or not 0 <= retries <= 5:
323
+ raise ConfigError(f"{path}.transient_retries must be between 0 and 5")
324
+ for boolean_name in ("allow_http_loopback", "allow_private_network"):
325
+ if not isinstance(merged.get(boolean_name, False), bool):
326
+ raise ConfigError(f"{path}.{boolean_name} must be a boolean")
327
+ return TargetConfig(
328
+ id=target_id,
329
+ mcp_url=mcp_url,
330
+ spec_profile=spec_profile,
331
+ correlation=_correlation(merged.get("correlation"), f"{path}.correlation"),
332
+ oauth=_oauth(merged.get("oauth"), f"{path}.oauth"),
333
+ expectations=_expectations(merged.get("expectations"), f"{path}.expectations"),
334
+ timeout_seconds=float(timeout),
335
+ max_response_bytes=max_bytes,
336
+ transient_retries=retries,
337
+ allow_http_loopback=merged.get("allow_http_loopback", False),
338
+ allow_private_network=merged.get("allow_private_network", False),
339
+ )
340
+
341
+
342
+ def _parse(document: Any) -> Manifest:
343
+ root = _mapping(document, "manifest")
344
+ _only(root, {"schema", "defaults", "targets", "profiles"}, "manifest")
345
+ schema = _string(root.get("schema"), "schema", required=True)
346
+ if schema != CONFIG_SCHEMA:
347
+ raise ConfigError(f"unsupported schema {schema!r}; expected {CONFIG_SCHEMA!r}")
348
+ defaults = _mapping(root.get("defaults"), "defaults")
349
+ _only(
350
+ defaults,
351
+ {
352
+ "spec_profile",
353
+ "timeout_seconds",
354
+ "max_response_bytes",
355
+ "transient_retries",
356
+ "allow_http_loopback",
357
+ "allow_private_network",
358
+ },
359
+ "defaults",
360
+ )
361
+ target_values = _mapping(root.get("targets"), "targets")
362
+ if not target_values:
363
+ raise ConfigError("manifest must define at least one target")
364
+ targets = {
365
+ target_id: _target(target_id, value, defaults) for target_id, value in target_values.items()
366
+ }
367
+ profile_values = _mapping(root.get("profiles"), "profiles")
368
+ profiles: dict[str, RunProfile] = {}
369
+ for profile_name, value in profile_values.items():
370
+ if not profile_name:
371
+ raise ConfigError("profile names must be non-empty strings")
372
+ data = _mapping(value, f"profiles.{profile_name}")
373
+ _only(data, {"targets"}, f"profiles.{profile_name}")
374
+ selected = _strings(data.get("targets"), f"profiles.{profile_name}.targets")
375
+ if not selected:
376
+ raise ConfigError(f"profiles.{profile_name}.targets must not be empty")
377
+ missing = sorted(set(selected) - set(targets))
378
+ if missing:
379
+ raise ConfigError(
380
+ f"profiles.{profile_name} names unknown targets: {', '.join(missing)}"
381
+ )
382
+ profiles[profile_name] = RunProfile(targets=selected)
383
+ return Manifest(schema=schema, targets=targets, profiles=profiles)
384
+
385
+
386
+ def loads_manifest(content: str, *, source: str = "<string>") -> Manifest:
387
+ try:
388
+ document = yaml.safe_load(content)
389
+ except yaml.YAMLError as exc:
390
+ raise ConfigError(f"invalid YAML/JSON in {source}") from exc
391
+ return _parse(document)
392
+
393
+
394
+ def load_manifest(path: str | Path) -> Manifest:
395
+ source = Path(path)
396
+ try:
397
+ content = source.read_text(encoding="utf-8")
398
+ except OSError as exc:
399
+ raise ConfigError(f"cannot read manifest {source}") from exc
400
+ return loads_manifest(content, source=str(source))
401
+
402
+
403
+ def _json_schema(filename: str) -> dict[str, Any]:
404
+ schema = files("testmcpy_oauth_probe").joinpath(f"schemas/{filename}")
405
+ value = json.loads(schema.read_text(encoding="utf-8"))
406
+ if not isinstance(value, dict): # pragma: no cover - package invariant
407
+ raise RuntimeError("packaged schema is invalid")
408
+ return value
409
+
410
+
411
+ def manifest_json_schema() -> dict[str, Any]:
412
+ """Return the packaged machine-readable manifest v1 schema."""
413
+ return _json_schema("oauth-smoke-v1.schema.json")
414
+
415
+
416
+ def report_json_schema() -> dict[str, Any]:
417
+ """Return the packaged machine-readable report v1 schema."""
418
+ return _json_schema("oauth-smoke-report-v1.schema.json")
419
+
420
+
421
+ def dump_manifest_schema() -> str:
422
+ return json.dumps(manifest_json_schema(), indent=2, sort_keys=True)
423
+
424
+
425
+ def dump_report_schema() -> str:
426
+ return json.dumps(report_json_schema(), indent=2, sort_keys=True)