agent-easy-framework 0.0.3__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.
Files changed (68) hide show
  1. agent_easy_framework-0.0.3.dist-info/METADATA +499 -0
  2. agent_easy_framework-0.0.3.dist-info/RECORD +68 -0
  3. agent_easy_framework-0.0.3.dist-info/WHEEL +4 -0
  4. agent_easy_framework-0.0.3.dist-info/entry_points.txt +2 -0
  5. agent_easy_framework-0.0.3.dist-info/licenses/LICENSE +21 -0
  6. agent_server/__about__.py +3 -0
  7. agent_server/__init__.py +13 -0
  8. agent_server/__main__.py +8 -0
  9. agent_server/auth/__init__.py +1 -0
  10. agent_server/auth/base.py +67 -0
  11. agent_server/auth/basic.py +95 -0
  12. agent_server/auth/none.py +28 -0
  13. agent_server/auth/oidc.py +114 -0
  14. agent_server/bootstrap.py +32 -0
  15. agent_server/checks/__init__.py +12 -0
  16. agent_server/checks/invariants.py +324 -0
  17. agent_server/cli.py +56 -0
  18. agent_server/config/__init__.py +1 -0
  19. agent_server/config/loader.py +102 -0
  20. agent_server/config/schema.py +61 -0
  21. agent_server/config/secrets/__init__.py +1 -0
  22. agent_server/config/secrets/base.py +53 -0
  23. agent_server/config/secrets/file_provider.py +46 -0
  24. agent_server/core/__init__.py +1 -0
  25. agent_server/core/context.py +54 -0
  26. agent_server/core/registry.py +155 -0
  27. agent_server/core/tools/__init__.py +12 -0
  28. agent_server/core/tools/example.py +28 -0
  29. agent_server/datasources/__init__.py +1 -0
  30. agent_server/datasources/base.py +61 -0
  31. agent_server/datasources/neo4j.py +72 -0
  32. agent_server/datasources/postgres.py +76 -0
  33. agent_server/docs/__init__.py +16 -0
  34. agent_server/docs/server.py +176 -0
  35. agent_server/ops/__init__.py +1 -0
  36. agent_server/ops/app.py +59 -0
  37. agent_server/ops/schemas.py +35 -0
  38. agent_server/py.typed +0 -0
  39. agent_server/runtime.py +39 -0
  40. agent_server/transports/__init__.py +55 -0
  41. agent_server/transports/dispatch.py +28 -0
  42. agent_server/transports/grpc/__init__.py +10 -0
  43. agent_server/transports/grpc/agent_server_pb2.py +45 -0
  44. agent_server/transports/grpc/agent_server_pb2_grpc.py +153 -0
  45. agent_server/transports/grpc/server.py +110 -0
  46. agent_server/transports/http.py +84 -0
  47. agent_server/transports/mcp_app.py +72 -0
  48. agent_server/transports/stdio.py +27 -0
  49. create_agent_server/__about__.py +7 -0
  50. create_agent_server/__init__.py +17 -0
  51. create_agent_server/add_tool.py +167 -0
  52. create_agent_server/cli.py +205 -0
  53. create_agent_server/generator.py +832 -0
  54. create_agent_server/py.typed +0 -0
  55. create_agent_server/template_paths.py +89 -0
  56. create_agent_server/templates/config/base.yaml +25 -0
  57. create_agent_server/templates/config/profiles/local-http.yaml +10 -0
  58. create_agent_server/templates/config/profiles/local.yaml +6 -0
  59. create_agent_server/templates/config/profiles/prod.yaml +32 -0
  60. create_agent_server/templates/examples/README.md +125 -0
  61. create_agent_server/templates/examples/call_ping.py +43 -0
  62. create_agent_server/templates/examples/call_ping_http.py +52 -0
  63. create_agent_server/templates/examples/smoke_ops.sh +26 -0
  64. create_agent_server/templates/gitignore +15 -0
  65. create_agent_server/templates/pre-commit-config.yaml +42 -0
  66. create_agent_server/templates/proto/agent_server.proto +35 -0
  67. create_agent_server/templates/tools/checks/run_checks.py +63 -0
  68. create_agent_server/templates/tools/checks/validate_typing.py +74 -0
@@ -0,0 +1,67 @@
1
+ """Pluggable authentication.
2
+
3
+ Auth providers validate inbound credentials and return a :class:`Principal`, or
4
+ raise :class:`AuthError`. They register by name and are selected per profile via
5
+ ``server.auth.provider`` - the same plugin pattern as secrets and data sources.
6
+ Enforced on the HTTP and gRPC transports; stdio is a trusted local channel.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Callable, Mapping
12
+ from dataclasses import dataclass, field
13
+ from typing import Any, Protocol, runtime_checkable
14
+
15
+
16
+ class AuthError(Exception):
17
+ """Raised when authentication fails."""
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class Principal:
22
+ """The authenticated caller."""
23
+
24
+ subject: str
25
+ claims: Mapping[str, Any] = field(default_factory=dict)
26
+
27
+
28
+ @runtime_checkable
29
+ class AuthProvider(Protocol):
30
+ """Validates credentials carried in transport headers/metadata."""
31
+
32
+ async def authenticate(self, headers: Mapping[str, str]) -> Principal:
33
+ """Return the Principal for valid credentials, else raise AuthError."""
34
+ ...
35
+
36
+
37
+ AuthProviderFactory = Callable[[Mapping[str, Any]], AuthProvider]
38
+
39
+ _REGISTRY: dict[str, AuthProviderFactory] = {}
40
+
41
+
42
+ def register_auth_provider(
43
+ name: str,
44
+ ) -> Callable[[AuthProviderFactory], AuthProviderFactory]:
45
+ """Register an auth provider factory under ``name``."""
46
+
47
+ def decorate(factory: AuthProviderFactory) -> AuthProviderFactory:
48
+ if name in _REGISTRY:
49
+ raise ValueError(f"Auth provider {name!r} already registered")
50
+ _REGISTRY[name] = factory
51
+ return factory
52
+
53
+ return decorate
54
+
55
+
56
+ def build_auth_provider(name: str, options: Mapping[str, Any]) -> AuthProvider:
57
+ """Instantiate a registered auth provider by name."""
58
+ try:
59
+ factory = _REGISTRY[name]
60
+ except KeyError as exc:
61
+ known = ", ".join(sorted(_REGISTRY)) or "<none>"
62
+ raise KeyError(f"Unknown auth provider {name!r}. Registered: {known}") from exc
63
+ return factory(options)
64
+
65
+
66
+ def registered_auth_providers() -> tuple[str, ...]:
67
+ return tuple(sorted(_REGISTRY))
@@ -0,0 +1,95 @@
1
+ """Ephemeral API-key authentication for local testing.
2
+
3
+ This provider generates a fresh, random API key at construction time and
4
+ accepts only that key. It exists so that a freshly scaffolded server can be
5
+ exercised over an authenticated transport without standing up a real identity
6
+ provider.
7
+
8
+ It is deliberately **not** production-safe: the key lives only in process
9
+ memory, rotates on every restart, and is printed to the logs. To make that
10
+ impossible to miss, construction emits a loud, bordered WARNING that repeats
11
+ the key and states it is for testing only. Real deployments must select the
12
+ ``oidc`` provider instead.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ import secrets
19
+ from collections.abc import Mapping
20
+ from typing import Any
21
+
22
+ from agent_server.auth.base import (
23
+ AuthError,
24
+ AuthProvider,
25
+ Principal,
26
+ register_auth_provider,
27
+ )
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ def _normalize(headers: Mapping[str, str]) -> dict[str, str]:
33
+ """Return a case-insensitive view of ``headers`` keyed by lower-case name."""
34
+ return {key.lower(): value for key, value in headers.items()}
35
+
36
+
37
+ class BasicAuthProvider:
38
+ """Accept a single, runtime-generated API key.
39
+
40
+ The key is created with :func:`secrets.token_urlsafe` and compared in
41
+ constant time. Callers may present it as either ``Authorization: Bearer
42
+ <key>`` or ``X-Api-Key: <key>``.
43
+ """
44
+
45
+ def __init__(self, *, subject: str = "test-user") -> None:
46
+ self.subject = subject
47
+ self.api_key: str = secrets.token_urlsafe(32)
48
+ self._warn_testing_only()
49
+
50
+ def _warn_testing_only(self) -> None:
51
+ """Emit a hard-to-miss warning so the ephemeral key is never trusted."""
52
+ border = "=" * 72
53
+ logger.warning(
54
+ "\n%s\n"
55
+ "BasicAuthProvider generated an ephemeral API key:\n"
56
+ " %s\n"
57
+ "FOR TESTING PURPOSES ONLY - DO NOT USE IN PRODUCTION.\n"
58
+ "%s",
59
+ border,
60
+ self.api_key,
61
+ border,
62
+ )
63
+
64
+ async def authenticate(self, headers: Mapping[str, str]) -> Principal:
65
+ """Validate the presented key and return the configured principal.
66
+
67
+ Raises :class:`AuthError` when no key is supplied or it does not match.
68
+ """
69
+ presented = self._extract_key(headers)
70
+ if presented is None or not secrets.compare_digest(presented, self.api_key):
71
+ raise AuthError("Invalid or missing API key")
72
+ return Principal(
73
+ subject=self.subject,
74
+ claims={"auth": "basic", "testing_only": True},
75
+ )
76
+
77
+ @staticmethod
78
+ def _extract_key(headers: Mapping[str, str]) -> str | None:
79
+ """Read the key from an ``Authorization`` bearer or ``X-Api-Key`` header."""
80
+ normalized = _normalize(headers)
81
+ api_key = normalized.get("x-api-key")
82
+ if api_key:
83
+ return api_key.strip()
84
+ value = normalized.get("authorization")
85
+ if value:
86
+ scheme, _, token = value.partition(" ")
87
+ if scheme.lower() == "bearer" and token.strip():
88
+ return token.strip()
89
+ return None
90
+
91
+
92
+ @register_auth_provider("basic")
93
+ def _build(options: Mapping[str, Any]) -> AuthProvider:
94
+ """Construct a :class:`BasicAuthProvider` from profile options."""
95
+ return BasicAuthProvider(subject=options.get("subject", "test-user"))
@@ -0,0 +1,28 @@
1
+ """The default 'no authentication' provider.
2
+
3
+ Selected when ``server.auth.provider`` is ``none`` (the default). Every request
4
+ is accepted as an anonymous principal. Appropriate for stdio and trusted
5
+ internal networks; production HTTP/gRPC deployments should select ``oidc``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Mapping
11
+ from typing import Any
12
+
13
+ from agent_server.auth.base import AuthProvider, Principal, register_auth_provider
14
+
15
+
16
+ class NoAuthProvider:
17
+ """Accept every caller as anonymous."""
18
+
19
+ async def authenticate(self, headers: Mapping[str, str]) -> Principal:
20
+ # No credentials are inspected; every caller is anonymous. We bind the
21
+ # parameter explicitly so the interface stays honest under all builds.
22
+ _ = headers
23
+ return Principal(subject="anonymous", claims={})
24
+
25
+
26
+ @register_auth_provider("none")
27
+ def _build(options: Mapping[str, Any]) -> AuthProvider:
28
+ return NoAuthProvider()
@@ -0,0 +1,114 @@
1
+ """OIDC / OAuth2 bearer-JWT authentication.
2
+
3
+ Validates inbound ``Authorization: Bearer <jwt>`` credentials against an
4
+ OpenID Connect / OAuth2 identity provider. Signing keys are fetched from the
5
+ issuer's JWKS endpoint and the token is verified for signature, ``audience``,
6
+ and ``issuer``. This is the recommended provider for production HTTP/gRPC
7
+ deployments.
8
+
9
+ The heavy dependencies (``PyJWT`` and its crypto backend, plus the JWKS HTTP
10
+ client) are imported lazily inside :meth:`OidcAuthProvider.authenticate`. A
11
+ bootstrap importer loads every provider module unconditionally, so importing
12
+ this module must never require the optional ``oidc`` extra to be installed.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections.abc import Mapping, Sequence
18
+ from typing import TYPE_CHECKING, Any
19
+
20
+ from agent_server.auth.base import (
21
+ AuthError,
22
+ AuthProvider,
23
+ Principal,
24
+ register_auth_provider,
25
+ )
26
+
27
+ if TYPE_CHECKING:
28
+ from jwt import PyJWKClient
29
+
30
+
31
+ def _normalize(headers: Mapping[str, str]) -> dict[str, str]:
32
+ """Return a case-insensitive view of ``headers`` keyed by lower-case name."""
33
+ return {key.lower(): value for key, value in headers.items()}
34
+
35
+
36
+ class OidcAuthProvider:
37
+ """Authenticate callers via signed OIDC/OAuth2 bearer JWTs.
38
+
39
+ The JWKS client is created on first use and cached on the instance so that
40
+ signing keys are reused across requests (PyJWKClient performs its own key
41
+ caching internally).
42
+ """
43
+
44
+ def __init__(
45
+ self,
46
+ *,
47
+ issuer: str,
48
+ audience: str,
49
+ jwks_uri: str,
50
+ algorithms: Sequence[str] | None = None,
51
+ ) -> None:
52
+ self.issuer = issuer
53
+ self.audience = audience
54
+ self.jwks_uri = jwks_uri
55
+ self.algorithms: list[str] = list(algorithms) if algorithms else ["RS256"]
56
+ self._jwks_client: PyJWKClient | None = None
57
+
58
+ def _client(self) -> PyJWKClient:
59
+ """Lazily construct and cache the JWKS client."""
60
+ if self._jwks_client is None:
61
+ from jwt import PyJWKClient # noqa: PLC0415
62
+
63
+ self._jwks_client = PyJWKClient(self.jwks_uri)
64
+ return self._jwks_client
65
+
66
+ async def authenticate(self, headers: Mapping[str, str]) -> Principal:
67
+ """Validate the bearer JWT and return the caller's :class:`Principal`.
68
+
69
+ Raises :class:`AuthError` if the ``Authorization`` header is missing or
70
+ malformed, or if signature/claim verification fails.
71
+ """
72
+ token = self._extract_bearer(headers)
73
+
74
+ import jwt # noqa: PLC0415
75
+
76
+ try:
77
+ signing_key = self._client().get_signing_key_from_jwt(token)
78
+ claims: dict[str, Any] = jwt.decode(
79
+ token,
80
+ signing_key.key,
81
+ algorithms=self.algorithms,
82
+ audience=self.audience,
83
+ issuer=self.issuer,
84
+ )
85
+ except Exception as exc:
86
+ raise AuthError("OIDC token verification failed") from exc
87
+
88
+ subject = claims.get("sub")
89
+ if not isinstance(subject, str) or not subject:
90
+ raise AuthError("OIDC token missing 'sub' claim")
91
+ return Principal(subject=subject, claims=claims)
92
+
93
+ @staticmethod
94
+ def _extract_bearer(headers: Mapping[str, str]) -> str:
95
+ """Pull the raw token out of an ``Authorization: Bearer <token>`` header."""
96
+ normalized = _normalize(headers)
97
+ value = normalized.get("authorization")
98
+ if not value:
99
+ raise AuthError("Missing Authorization header")
100
+ scheme, _, token = value.partition(" ")
101
+ if scheme.lower() != "bearer" or not token.strip():
102
+ raise AuthError("Malformed Authorization header; expected 'Bearer <token>'")
103
+ return token.strip()
104
+
105
+
106
+ @register_auth_provider("oidc")
107
+ def _build(options: Mapping[str, Any]) -> AuthProvider:
108
+ """Construct an :class:`OidcAuthProvider` from profile options."""
109
+ return OidcAuthProvider(
110
+ issuer=options["issuer"],
111
+ audience=options["audience"],
112
+ jwks_uri=options["jwks_uri"],
113
+ algorithms=options.get("algorithms"),
114
+ )
@@ -0,0 +1,32 @@
1
+ """Import-time registration of all pluggable components.
2
+
3
+ Calling :func:`bootstrap` imports every provider and tool module so their
4
+ ``@register_*`` / ``@tool`` side effects populate the registries. Provider
5
+ modules must keep heavy third-party imports inside their factories (not at
6
+ module top level) so this stays import-safe even when an optional dependency is
7
+ not installed.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import importlib
13
+
14
+ _MODULES: tuple[str, ...] = (
15
+ "agent_server.config.secrets.file_provider",
16
+ "agent_server.auth.none",
17
+ "agent_server.auth.oidc",
18
+ "agent_server.auth.basic",
19
+ "agent_server.datasources.postgres",
20
+ "agent_server.datasources.neo4j",
21
+ "agent_server.core.tools",
22
+ )
23
+
24
+ _imported: set[str] = set()
25
+
26
+
27
+ def bootstrap() -> None:
28
+ """Idempotently import all registration modules."""
29
+ for module in _MODULES:
30
+ if module not in _imported:
31
+ importlib.import_module(module)
32
+ _imported.add(module)
@@ -0,0 +1,12 @@
1
+ """Golden-path fact-checks: importable invariants for an agent-server project.
2
+
3
+ These checks codify what it means for a project scaffolded by this framework to
4
+ stay on the golden path. They are deliberately importable (not just a script) so
5
+ pre-commit, ``make factcheck``, and tests can all share one implementation.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from agent_server.checks.invariants import Problem, check_project
11
+
12
+ __all__ = ["Problem", "check_project"]
@@ -0,0 +1,324 @@
1
+ """The golden-path invariants, as importable fact-check logic.
2
+
3
+ :func:`check_project` runs eight invariants against a project directory and returns
4
+ one :class:`Problem` per violation (an empty list means the project is healthy).
5
+ The checks introspect the *running* code where structure alone cannot prove
6
+ correctness - importing the project package, bootstrapping its registries, and
7
+ loading every config profile. Every check degrades gracefully: if something
8
+ cannot be loaded it reports a ``Problem`` rather than raising, so a broken
9
+ sub-system never masks the rest of the report.
10
+
11
+ The single source of truth for "the package under test" is :data:`PACKAGE`. For
12
+ this repository that is ``agent_server``.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import importlib
18
+ import inspect
19
+ import re
20
+ from collections.abc import Iterator
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ import yaml
26
+ from pydantic import BaseModel
27
+
28
+ PACKAGE = "agent_server"
29
+
30
+ # Keys whose string values must never hold a raw secret. Matched case-insensitively
31
+ # as a substring so e.g. ``db_password`` and ``apiKey`` are caught.
32
+ _SECRET_KEY = re.compile(r"(password|secret|token|apikey|api_key|private_key)", re.IGNORECASE)
33
+ # A value is acceptable iff it is purely a ``${secret:...}`` reference.
34
+ _SECRET_REF = re.compile(r"\$\{secret:[^}]+\}")
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Problem:
39
+ """A single invariant violation: a stable ``code`` and a human ``message``."""
40
+
41
+ code: str
42
+ message: str
43
+
44
+
45
+ def check_project(project_dir: Path) -> list[Problem]:
46
+ """Run every golden-path invariant and collect the violations."""
47
+ project_dir = project_dir.resolve()
48
+ problems: list[Problem] = []
49
+ problems += _check_required_files(project_dir)
50
+ problems += _check_ops_endpoints()
51
+ problems += _check_tool_contract()
52
+ problems += _check_config_and_secrets(project_dir)
53
+ problems += _check_datasource_resolution(project_dir)
54
+ problems += _check_docs_in_sync()
55
+ problems += _check_ops_typed_responses()
56
+ problems += _check_transport_separation()
57
+ return problems
58
+
59
+
60
+ def _check_required_files(project_dir: Path) -> list[Problem]:
61
+ """Invariant 1: the project layout the framework guarantees is present."""
62
+ problems: list[Problem] = []
63
+
64
+ base = project_dir / "config" / "base.yaml"
65
+ if not base.is_file():
66
+ problems.append(Problem("files.base_config", "config/base.yaml is missing"))
67
+
68
+ profiles = project_dir / "config" / "profiles"
69
+ if not profiles.is_dir():
70
+ problems.append(Problem("files.profiles_dir", "config/profiles/ is missing"))
71
+ elif not any(profiles.glob("*.yaml")) and not any(profiles.glob("*.yml")):
72
+ problems.append(
73
+ Problem("files.profiles_empty", "config/profiles/ contains no profile YAML")
74
+ )
75
+
76
+ src = project_dir / "src"
77
+ packages = (
78
+ [p for p in src.iterdir() if p.is_dir() and (p / "__init__.py").is_file()]
79
+ if src.is_dir()
80
+ else []
81
+ )
82
+ if not packages:
83
+ problems.append(Problem("files.src_package", "no src/<pkg> package with __init__.py found"))
84
+
85
+ for name in ("Makefile", ".pre-commit-config.yaml"):
86
+ if not (project_dir / name).is_file():
87
+ problems.append(Problem("files.required", f"{name} is missing"))
88
+
89
+ return problems
90
+
91
+
92
+ def _check_ops_endpoints() -> list[Problem]:
93
+ """Invariant 2: the ops app exposes /health and /ready."""
94
+ try:
95
+ ctx = _build_context()
96
+ except Exception as exc:
97
+ return [Problem("ops.context", f"could not build app context: {exc}")]
98
+
99
+ try:
100
+ ops = importlib.import_module(f"{PACKAGE}.ops.app")
101
+ app = ops.create_ops_app(ctx)
102
+ except Exception as exc:
103
+ return [Problem("ops.import", f"could not build ops app: {exc}")]
104
+
105
+ paths = {getattr(route, "path", None) for route in app.routes}
106
+ return [
107
+ Problem("ops.endpoint", f"ops app does not expose {required}")
108
+ for required in ("/health", "/ready")
109
+ if required not in paths
110
+ ]
111
+
112
+
113
+ def _check_tool_contract() -> list[Problem]:
114
+ """Invariant 3: every registered tool honours the tool contract."""
115
+ try:
116
+ registry = _registry()
117
+ except Exception as exc:
118
+ return [Problem("tools.registry", f"could not load tool registry: {exc}")]
119
+
120
+ problems: list[Problem] = []
121
+ for spec in registry.all():
122
+ label = getattr(spec, "name", None) or "<unnamed>"
123
+ if not getattr(spec, "name", ""):
124
+ problems.append(Problem("tools.name", "a tool has an empty name"))
125
+ if not (getattr(spec, "description", "") or "").strip():
126
+ problems.append(Problem("tools.description", f"tool {label!r} has no description"))
127
+ model = getattr(spec, "input_model", None)
128
+ if not (isinstance(model, type) and issubclass(model, BaseModel)):
129
+ problems.append(
130
+ Problem("tools.input_model", f"tool {label!r} input_model is not a BaseModel")
131
+ )
132
+ output = getattr(spec, "output_model", None)
133
+ if not (isinstance(output, type) and issubclass(output, BaseModel)):
134
+ problems.append(
135
+ Problem("tools.output_model", f"tool {label!r} output_model is not a BaseModel")
136
+ )
137
+ return problems
138
+
139
+
140
+ def _check_config_and_secrets(project_dir: Path) -> list[Problem]:
141
+ """Invariant 4: every profile validates, and no raw secrets are committed."""
142
+ problems: list[Problem] = []
143
+ config_dir = project_dir / "config"
144
+ profiles_dir = config_dir / "profiles"
145
+
146
+ try:
147
+ loader = importlib.import_module(f"{PACKAGE}.config.loader")
148
+ except Exception as exc:
149
+ return [Problem("config.loader", f"could not import config loader: {exc}")]
150
+
151
+ profile_files = sorted(profiles_dir.glob("*.yaml")) + sorted(profiles_dir.glob("*.yml"))
152
+ for profile_file in profile_files:
153
+ name = profile_file.stem
154
+ try:
155
+ loader.load_config(profile=name, config_dir=config_dir, resolve_secrets=False)
156
+ except Exception as exc:
157
+ problems.append(
158
+ Problem("config.invalid", f"profile {name!r} fails to load/validate: {exc}")
159
+ )
160
+
161
+ base = config_dir / "base.yaml"
162
+ for yaml_file in ([base] if base.is_file() else []) + profile_files:
163
+ problems += _scan_for_raw_secrets(yaml_file)
164
+
165
+ return problems
166
+
167
+
168
+ def _scan_for_raw_secrets(path: Path) -> list[Problem]:
169
+ try:
170
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
171
+ except Exception as exc:
172
+ return [Problem("config.unreadable", f"{path.name} could not be parsed: {exc}")]
173
+
174
+ problems: list[Problem] = []
175
+ for key, value in _walk(data):
176
+ if (
177
+ isinstance(value, str)
178
+ and value.strip()
179
+ and _SECRET_KEY.search(key)
180
+ and not _SECRET_REF.fullmatch(value.strip())
181
+ ):
182
+ problems.append(
183
+ Problem(
184
+ "secrets.raw",
185
+ f"{path.name}: key {key!r} looks like a raw secret; "
186
+ f"use a ${{secret:...}} reference",
187
+ )
188
+ )
189
+ return problems
190
+
191
+
192
+ def _walk(node: Any, key: str = "") -> Iterator[tuple[str, Any]]:
193
+ """Yield ``(key, value)`` for every scalar leaf in a nested structure."""
194
+ if isinstance(node, dict):
195
+ for k, v in node.items():
196
+ yield from _walk(v, str(k))
197
+ elif isinstance(node, list):
198
+ for item in node:
199
+ yield from _walk(item, key)
200
+ else:
201
+ yield key, node
202
+
203
+
204
+ def _check_datasource_resolution(project_dir: Path) -> list[Problem]:
205
+ """Invariant 5: every datasource type used in any profile is registered."""
206
+ config_dir = project_dir / "config"
207
+ profiles_dir = config_dir / "profiles"
208
+
209
+ try:
210
+ _bootstrap()
211
+ ds_base = importlib.import_module(f"{PACKAGE}.datasources.base")
212
+ loader = importlib.import_module(f"{PACKAGE}.config.loader")
213
+ except Exception as exc:
214
+ return [Problem("datasources.import", f"could not load datasource registry: {exc}")]
215
+
216
+ registered = set(ds_base.registered_datasources())
217
+ problems: list[Problem] = []
218
+ seen: set[tuple[str, str]] = set()
219
+ for profile_file in sorted(profiles_dir.glob("*.yaml")) + sorted(profiles_dir.glob("*.yml")):
220
+ name = profile_file.stem
221
+ try:
222
+ cfg = loader.load_config(profile=name, config_dir=config_dir, resolve_secrets=False)
223
+ except Exception:
224
+ continue
225
+ for ds_name, ds in cfg.server.datasources.items():
226
+ if ds.type in registered:
227
+ continue
228
+ marker = (name, ds.type)
229
+ if marker in seen:
230
+ continue
231
+ seen.add(marker)
232
+ problems.append(
233
+ Problem(
234
+ "datasources.unregistered",
235
+ f"profile {name!r} datasource {ds_name!r} uses unregistered type {ds.type!r}",
236
+ )
237
+ )
238
+ return problems
239
+
240
+
241
+ def _check_docs_in_sync() -> list[Problem]:
242
+ """Invariant 6: the docs tool list covers every registered tool."""
243
+ try:
244
+ ctx = _build_context()
245
+ docs = importlib.import_module(f"{PACKAGE}.docs.server")
246
+ registry = _registry()
247
+ except Exception as exc:
248
+ return [Problem("docs.import", f"could not load docs generator: {exc}")]
249
+
250
+ documented = {str(entry["name"]) for entry in docs.tool_index(ctx)}
251
+ registered = set(registry.names())
252
+ missing = registered - documented
253
+ return [
254
+ Problem("docs.drift", f"tool {name!r} is registered but missing from the docs index")
255
+ for name in sorted(missing)
256
+ ]
257
+
258
+
259
+ def _check_ops_typed_responses() -> list[Problem]:
260
+ """Invariant 7: ops endpoints declare Pydantic response models."""
261
+ try:
262
+ ctx = _build_context()
263
+ ops = importlib.import_module(f"{PACKAGE}.ops.app")
264
+ app = ops.create_ops_app(ctx)
265
+ except Exception as exc:
266
+ return [Problem("ops.import", f"could not build ops app: {exc}")]
267
+
268
+ problems: list[Problem] = []
269
+ for route in app.routes:
270
+ path = getattr(route, "path", None)
271
+ if path not in {"/health", "/ready", "/admin/tools"}:
272
+ continue
273
+ model = getattr(route, "response_model", None)
274
+ if model is None:
275
+ problems.append(
276
+ Problem("ops.response_model", f"ops route {path!r} has no response_model")
277
+ )
278
+ return problems
279
+
280
+
281
+ def _check_transport_separation() -> list[Problem]:
282
+ """Invariant 8: auth stays in transport middleware; service layer stays typed."""
283
+ problems: list[Problem] = []
284
+ try:
285
+ mcp_app = importlib.import_module(f"{PACKAGE}.transports.mcp_app")
286
+ importlib.import_module(f"{PACKAGE}.transports.dispatch")
287
+ except Exception as exc:
288
+ return [Problem("transport.import", f"could not import transport modules: {exc}")]
289
+
290
+ try:
291
+ http = importlib.import_module(f"{PACKAGE}.transports.http")
292
+ except ModuleNotFoundError:
293
+ http = None
294
+
295
+ if http is not None and not hasattr(http, "_AuthMiddleware"):
296
+ problems.append(Problem("transport.auth", "HTTP transport is missing _AuthMiddleware"))
297
+
298
+ mcp_source = inspect.getsource(mcp_app)
299
+ if "authenticate" in mcp_source or "AuthProvider" in mcp_source:
300
+ problems.append(
301
+ Problem(
302
+ "transport.auth_leak",
303
+ "mcp_app must not perform auth; use transport middleware instead",
304
+ )
305
+ )
306
+
307
+ return problems
308
+
309
+
310
+ def _bootstrap() -> None:
311
+ importlib.import_module(f"{PACKAGE}.bootstrap").bootstrap()
312
+
313
+
314
+ def _registry() -> Any:
315
+ _bootstrap()
316
+ return importlib.import_module(f"{PACKAGE}.core.registry").get_registry()
317
+
318
+
319
+ def _build_context() -> Any:
320
+ """Bootstrap registries, load the active config, and build an AppContext."""
321
+ _bootstrap()
322
+ loader = importlib.import_module(f"{PACKAGE}.config.loader")
323
+ context = importlib.import_module(f"{PACKAGE}.core.context")
324
+ return context.AppContext(loader.load_config(resolve_secrets=False))