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.
- testmcpy_oauth_probe/__init__.py +44 -0
- testmcpy_oauth_probe/__main__.py +3 -0
- testmcpy_oauth_probe/cli.py +152 -0
- testmcpy_oauth_probe/config.py +426 -0
- testmcpy_oauth_probe/discovery.py +733 -0
- testmcpy_oauth_probe/mcp.py +332 -0
- testmcpy_oauth_probe/models.py +231 -0
- testmcpy_oauth_probe/oauth.py +583 -0
- testmcpy_oauth_probe/reporters.py +117 -0
- testmcpy_oauth_probe/runner.py +243 -0
- testmcpy_oauth_probe/schemas/oauth-smoke-report-v1.schema.json +78 -0
- testmcpy_oauth_probe/schemas/oauth-smoke-v1.schema.json +468 -0
- testmcpy_oauth_probe/secrets.py +118 -0
- testmcpy_oauth_probe/transport.py +225 -0
- testmcpy_oauth_probe-0.1.0.dist-info/METADATA +66 -0
- testmcpy_oauth_probe-0.1.0.dist-info/RECORD +19 -0
- testmcpy_oauth_probe-0.1.0.dist-info/WHEEL +5 -0
- testmcpy_oauth_probe-0.1.0.dist-info/entry_points.txt +2 -0
- testmcpy_oauth_probe-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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,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)
|