ygo74-agent-runtime-mcp 0.1.0__tar.gz

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,12 @@
1
+ Metadata-Version: 2.4
2
+ Name: ygo74-agent-runtime-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server hosting for the ygo74 agent runtime: transport, authentication and health
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: ygo74-agent-runtime-security==0.1.0
7
+ Requires-Dist: pydantic>=2.7
8
+ Provides-Extra: http
9
+ Requires-Dist: mcp<2,>=1.24; extra == "http"
10
+ Requires-Dist: starlette>=0.37; extra == "http"
11
+ Requires-Dist: uvicorn[standard]>=0.30; extra == "http"
12
+ Requires-Dist: httpx>=0.27; extra == "http"
@@ -0,0 +1,46 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "ygo74-agent-runtime-mcp"
7
+ version = "0.1.0"
8
+ description = "MCP server hosting for the ygo74 agent runtime: transport, authentication and health"
9
+ requires-python = ">=3.12"
10
+
11
+ # Hosting a Model Context Protocol *server* - the other side of
12
+ # `ygo74-agent-runtime-agents`' `domains.mcp`, which is the client.
13
+ #
14
+ # It depends on the security distribution and not on the agents one, deliberately:
15
+ # an MCP server has no agent, no conversation and no discovery descriptor, but it
16
+ # has exactly the same question to answer about who is calling. Sharing the
17
+ # authentication model is the point; sharing the agent machinery would be baggage.
18
+ dependencies = [
19
+ "ygo74-agent-runtime-security==0.1.0",
20
+ "pydantic>=2.7",
21
+ ]
22
+
23
+ [project.optional-dependencies]
24
+ # Serving a server over streamable HTTP. Stdio needs none of this, which is why it
25
+ # is an extra: a server reached as a child process should not have to install a web
26
+ # stack to be one.
27
+ #
28
+ # The `mcp` upper bound is deliberate - 2.0 moved to `httpx2` and reshaped the
29
+ # transport API. See the agents distribution for the full reason.
30
+ http = [
31
+ "mcp>=1.24,<2",
32
+ "starlette>=0.37",
33
+ "uvicorn[standard]>=0.30",
34
+ # `McpServerHost.verify_reachable` drives the application in-process, before the
35
+ # port is opened, to catch a server that would answer its own callers with 421.
36
+ # Starlette's test client is what runs the lifespan correctly while doing it, and
37
+ # it needs httpx - which `mcp` already brings in anyway.
38
+ "httpx>=0.27",
39
+ ]
40
+
41
+ [tool.setuptools.packages.find]
42
+ include = ["ygo74*"]
43
+ namespaces = true
44
+
45
+ [tool.setuptools.package-data]
46
+ "ygo74.agent_runtime.domains.mcpserver" = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,319 @@
1
+ """Hosting a Model Context Protocol server over HTTP.
2
+
3
+ Over stdio an MCP server's security argument is the operating system: it is a child
4
+ process, its credential never leaves it, and the caller talks to it through a pipe
5
+ nobody else can open. Over HTTP that argument is gone. The pipe becomes a port and
6
+ every process that can reach the port can reach the tools, while nothing else about
7
+ the server changes - same tools, same payloads - which is exactly why the difference
8
+ is easy to miss.
9
+
10
+ This host is the part that does not change between servers: the authentication
11
+ chain, an open health probe, OAuth discovery, and a start-up check on the one trap
12
+ that costs a production incident to find.
13
+
14
+ **Why a middleware rather than the SDK's `TokenVerifier`.** The MCP SDK can verify a
15
+ bearer token, and only that. An API key in ``x-api-key``, a Basic credential, a
16
+ Kerberos ticket - none is expressible through it. Running the runtime's
17
+ :class:`RequestAuthenticator` as ASGI middleware is what lets one authentication
18
+ model serve an agent and an MCP server instead of two models drifting apart.
19
+
20
+ The authenticated caller is attached to the request scope but is **not** yet the
21
+ authority on what a tool operates upon: tools still take the subject they act for as
22
+ an argument. Making the authenticated identity authoritative is the next piece of
23
+ work, and this host is shaped so that it does not have to be undone first.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import logging
29
+ from typing import TYPE_CHECKING, Any
30
+
31
+ from ygo74.agent_runtime.domains.auth.auth_errors import AuthenticationError
32
+ from ygo74.agent_runtime.domains.auth.authentication_policy import (
33
+ AuthenticationMode,
34
+ AuthenticationPolicy,
35
+ )
36
+ from ygo74.agent_runtime.domains.auth.jwt_authenticator import JwtAuthenticator
37
+ from ygo74.agent_runtime.domains.mcpserver.http_binding import (
38
+ HEALTH_PATH,
39
+ McpHttpBinding,
40
+ )
41
+ from ygo74.agent_runtime.domains.mcpserver.protected_resource import (
42
+ PROTECTED_RESOURCE_PATH,
43
+ ProtectedResource,
44
+ )
45
+ from ygo74.agent_runtime.domains.mcpserver.server_errors import (
46
+ McpServerConfigurationError,
47
+ McpServerUnreachableError,
48
+ )
49
+
50
+ if TYPE_CHECKING: # pragma: no cover - imported for typing only
51
+ from starlette.applications import Starlette
52
+
53
+ # The scope key the authenticated caller is published under. Named rather than
54
+ # inlined because the next piece of work - making that identity authoritative over
55
+ # what a tool operates upon - reads it.
56
+ AUTH_CONTEXT_KEY = "ygo74.auth_context"
57
+
58
+ _logger = logging.getLogger(__name__)
59
+
60
+
61
+ class McpServerHost:
62
+ """Serves an MCP application to callers that prove who they are.
63
+
64
+ Args:
65
+ policy: Which authentication schemes this server accepts. There is no
66
+ default: serving everyone is a decision, and
67
+ :meth:`AuthenticationPolicy.anonymous` is how it is said.
68
+ binding: Where the server listens and what callers reach it at.
69
+ resource_url: The canonical URL of this server, required in JWT mode so it
70
+ can name itself as an OAuth resource. Ignored otherwise.
71
+ scopes: Scopes a client should request, when the deployment requires any.
72
+ """
73
+
74
+ def __init__(
75
+ self,
76
+ *,
77
+ policy: AuthenticationPolicy,
78
+ binding: McpHttpBinding | None = None,
79
+ resource_url: str = "",
80
+ scopes: tuple[str, ...] = (),
81
+ ) -> None:
82
+ self._policy = policy
83
+ self._binding = binding or McpHttpBinding()
84
+ self._authenticator = policy.build()
85
+ self._resource = self._protected_resource(policy, resource_url, scopes)
86
+
87
+ @property
88
+ def binding(self) -> McpHttpBinding:
89
+ """Where this server listens."""
90
+ return self._binding
91
+
92
+ @property
93
+ def protected_resource(self) -> ProtectedResource | None:
94
+ """The OAuth metadata this server publishes, when it publishes any."""
95
+ return self._resource
96
+
97
+ def application(self, tools: Starlette) -> Starlette:
98
+ """Wrap an MCP application with authentication, health and discovery.
99
+
100
+ ``tools`` is what ``FastMCP.streamable_http_app()`` returns. The host adds
101
+ routes rather than replacing them, so a server keeps whatever else it
102
+ exposes.
103
+ """
104
+ from starlette.middleware.base import BaseHTTPMiddleware
105
+ from starlette.requests import Request
106
+ from starlette.responses import JSONResponse
107
+ from starlette.routing import Route
108
+
109
+ async def health(_request: Request) -> JSONResponse:
110
+ return JSONResponse({"status": "ok"})
111
+
112
+ async def metadata(_request: Request) -> JSONResponse:
113
+ # Only reachable when a resource was configured, because the route is
114
+ # only added then. A 404 elsewhere is the honest answer: this server
115
+ # implements no OAuth flow.
116
+ assert self._resource is not None
117
+ return JSONResponse(self._resource.metadata())
118
+
119
+ async def guard(request: Request, call_next: Any) -> Any:
120
+ if self._is_open(request.url.path):
121
+ return await call_next(request)
122
+
123
+ try:
124
+ context = self._authenticator.authenticate(dict(request.headers))
125
+ except AuthenticationError:
126
+ return self._refusal(request.url.path)
127
+ except Exception as failure: # noqa: BLE001 - translated, not swallowed
128
+ # An authenticator a host wrote itself. The built-in schemes turn a
129
+ # malformed credential into an AuthenticationError, but Basic,
130
+ # Kerberos or whatever a deployment actually has is arbitrary code
131
+ # on the request path, and an unauthenticated caller able to raise
132
+ # inside the process holding the credential is a log-flood vector on
133
+ # the one surface meant to be hard.
134
+ #
135
+ # Failing closed is the whole translation: a scheme that could not
136
+ # decide is a scheme that did not authenticate.
137
+ _logger.warning(
138
+ "authenticator %s raised %s; refusing the request",
139
+ type(self._authenticator).__name__,
140
+ type(failure).__name__,
141
+ )
142
+ return self._refusal(request.url.path)
143
+
144
+ request.scope[AUTH_CONTEXT_KEY] = context
145
+ return await call_next(request)
146
+
147
+ tools.router.routes.append(Route(HEALTH_PATH, health, methods=["GET"]))
148
+ if self._resource is not None:
149
+ tools.router.routes.append(Route(PROTECTED_RESOURCE_PATH, metadata, methods=["GET"]))
150
+ tools.add_middleware(BaseHTTPMiddleware, dispatch=guard)
151
+ return tools
152
+
153
+ def verify_reachable(self, transport_security: object | None) -> None:
154
+ """Refuse to serve a server that would reject its own callers.
155
+
156
+ ``FastMCP`` derives a DNS-rebinding allow-list from the bind address at
157
+ construction and never revisits it, then enforces it *inside* the streamable
158
+ HTTP handler. A server built with the default address therefore answers every
159
+ request carrying a service name in ``Host`` with 421 - after authentication,
160
+ before any tool, with the health probe still green because the probe never
161
+ reaches that handler.
162
+
163
+ The allow-list is read rather than probed. A synthetic request cannot find
164
+ this: the only route that enforces the rule is the one this host has just
165
+ put an authentication guard in front of, so a probe would be refused at the
166
+ door and learn nothing. Reading the configuration is also a better error -
167
+ it can say which host was expected and which were allowed.
168
+ """
169
+ allowed = self._allowed_hosts(transport_security)
170
+ if allowed is None:
171
+ return
172
+
173
+ public_host = self._binding.public_host
174
+ if any(_host_matches(public_host, pattern) for pattern in allowed):
175
+ return
176
+
177
+ raise McpServerUnreachableError(
178
+ f"this server would answer 421 to a request for host {public_host!r}: its DNS-rebinding "
179
+ f"allow-list is {sorted(allowed)}, derived from a bind address that does not include it. "
180
+ "FastMCP freezes that allow-list at construction, so pass the real bind address to the "
181
+ "constructor rather than assigning to settings afterwards"
182
+ )
183
+
184
+ def serve(self, server: object) -> None:
185
+ """Verify, wrap, then serve until stopped.
186
+
187
+ Takes the ``FastMCP`` server rather than its application, because the
188
+ allow-list that decides reachability lives in its settings and is gone by the
189
+ time the application exists.
190
+ """
191
+ import uvicorn
192
+
193
+ settings = getattr(server, "settings", None)
194
+ self.verify_reachable(getattr(settings, "transport_security", None))
195
+
196
+ application = self.application(server.streamable_http_app()) # type: ignore[attr-defined]
197
+ _logger.info(
198
+ "serving MCP over HTTP on %s:%s as %s - %s",
199
+ self._binding.host,
200
+ self._binding.port,
201
+ self._binding.public_host,
202
+ self._policy.describe(),
203
+ )
204
+ uvicorn.run(application, host=self._binding.host, port=self._binding.port)
205
+
206
+ @staticmethod
207
+ def _allowed_hosts(transport_security: object | None) -> list[str] | None:
208
+ """The hosts a server accepts, or None when it accepts every host.
209
+
210
+ ``None`` covers both "no transport security" and "protection switched off",
211
+ which is what FastMCP produces for any non-loopback bind address.
212
+ """
213
+ if transport_security is None:
214
+ return None
215
+ if not getattr(transport_security, "enable_dns_rebinding_protection", False):
216
+ return None
217
+ return list(getattr(transport_security, "allowed_hosts", []) or [])
218
+
219
+ def _is_open(self, path: str) -> bool:
220
+ """Whether a path is reachable without a credential.
221
+
222
+ Compared exactly, not by prefix: a probe an orchestrator can call and a
223
+ metadata document a client needs before it has a token are the only two, and
224
+ `/healthzextra` is not either of them.
225
+
226
+ A plain comparison, deliberately. Constant time buys nothing here - both
227
+ paths are published constants, not secrets - and `hmac.compare_digest`
228
+ raises on non-ASCII `str`, which an ASGI server hands over verbatim after
229
+ percent-decoding. That would turn any unauthenticated request for `/%C3%A9`
230
+ into a 500 and a traceback in the log of a credential-holding process: the
231
+ exact log-flood vector this guard exists to close, arriving through the
232
+ guard itself.
233
+ """
234
+ return path in self._open_paths()
235
+
236
+ def _open_paths(self) -> tuple[str, ...]:
237
+ """Paths reachable without a credential.
238
+
239
+ The metadata path is open whether or not this server publishes it. When it
240
+ does, a client must be able to read it before it has a token; when it does
241
+ not, routing answers 404 - which is the honest "I am not an OAuth resource
242
+ server". Guarding it instead would answer 401, and a client probing for
243
+ OAuth support would read that as "there is a flow here, you just need a
244
+ credential".
245
+ """
246
+ return (HEALTH_PATH, PROTECTED_RESOURCE_PATH)
247
+
248
+ def _refusal(self, path: str) -> Any:
249
+ """Answer a caller that could not prove itself.
250
+
251
+ No detail about what was wrong: which part failed is information a guesser
252
+ can use, and the operator has the server log.
253
+
254
+ The path is quoted before it is logged. It is attacker-controlled and
255
+ percent-decoded by the server, so a request for `/%0AINFO:%20all%20clear`
256
+ would otherwise write a second, forged line into the same log.
257
+ """
258
+ from starlette.responses import JSONResponse
259
+
260
+ _logger.warning("refused an unauthenticated request to %r", path)
261
+ headers = {} if self._resource is None else {"WWW-Authenticate": self._resource.challenge()}
262
+ return JSONResponse({"error": "unauthorized"}, status_code=401, headers=headers)
263
+
264
+ @staticmethod
265
+ def _protected_resource(
266
+ policy: AuthenticationPolicy,
267
+ resource_url: str,
268
+ scopes: tuple[str, ...],
269
+ ) -> ProtectedResource | None:
270
+ """Build the OAuth metadata, when this server is an OAuth resource server."""
271
+ if policy.mode is not AuthenticationMode.JWT:
272
+ return None
273
+
274
+ if not resource_url.strip():
275
+ raise McpServerConfigurationError(
276
+ "serving JWT-authenticated MCP requires resource_url: a resource server that "
277
+ "cannot name itself cannot be discovered, and a client has no way to learn "
278
+ "which issuer to authenticate against"
279
+ )
280
+
281
+ issuer = _issuer_of(policy)
282
+ if not issuer:
283
+ raise McpServerConfigurationError(
284
+ "serving JWT-authenticated MCP requires an issuer in the validation config: "
285
+ "without one there is no authorization server to advertise"
286
+ )
287
+
288
+ return ProtectedResource(resource_url.rstrip("/"), issuer, scopes)
289
+
290
+
291
+ def _issuer_of(policy: AuthenticationPolicy) -> str:
292
+ """Read the issuer out of the policy's JWT authenticator."""
293
+ for authenticator in policy.authenticators:
294
+ config = getattr(authenticator, "config", None) if isinstance(authenticator, JwtAuthenticator) else None
295
+ if config is not None and config.issuer:
296
+ return str(config.issuer)
297
+ return ""
298
+
299
+
300
+ def _host_matches(host: str, pattern: str) -> bool:
301
+ """Whether a host satisfies an allow-list entry, as FastMCP decides it.
302
+
303
+ Its rule is an exact match, or a pattern ending in ``:*`` whose base the host
304
+ carries followed by a colon. Nothing else - no ``*`` on its own, no ``?``, no
305
+ character classes.
306
+
307
+ Reimplemented rather than approximated with :func:`fnmatch`, which reads a
308
+ strictly larger grammar and disagrees in both directions. ``["*"]`` is the
309
+ natural way to write "allow every host" and ``fnmatch`` accepts it, while
310
+ FastMCP refuses every request - green at boot, 421 for every caller, which is
311
+ precisely the incident this check exists to prevent. In the other direction
312
+ ``fnmatch`` reads ``[::1]`` as a character class and refuses a correctly
313
+ configured IPv6 deployment.
314
+ """
315
+ if host == pattern:
316
+ return True
317
+ if not pattern.endswith(":*"):
318
+ return False
319
+ return host.startswith(pattern[:-1])
@@ -0,0 +1,70 @@
1
+ """Where an MCP server listens, and what it calls itself.
2
+
3
+ Two addresses, and conflating them is what produced the worst defect this package
4
+ was written to prevent.
5
+
6
+ The **bind address** is which interfaces the process accepts connections on. In a
7
+ container that is every interface, because the container's network namespace is the
8
+ boundary; what keeps that safe is the credential, not the address.
9
+
10
+ The **public host** is the name callers use. It is what arrives in the ``Host``
11
+ header, what an OAuth resource identifier is built from, and what a DNS-rebinding
12
+ allow-list has to contain. A wildcard bind is not a name anything can address, so it
13
+ cannot serve as one.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass
19
+
20
+ HEALTH_PATH = "/healthz"
21
+
22
+ # Addresses that mean "every interface" rather than naming one. A caller cannot put
23
+ # any of them in a `Host` header, so they cannot stand in for a public name.
24
+ _WILDCARDS = frozenset({"0.0.0.0", "::", "[::]", ""})
25
+
26
+ _LOOPBACK = "localhost"
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class McpHttpBinding:
31
+ """The address an MCP server is served on.
32
+
33
+ Args:
34
+ host: The interface to bind. ``0.0.0.0`` is normal in a container.
35
+ port: The port to bind.
36
+ public_host: The authority callers reach the server at, including the port
37
+ when it is not implied - ``mail-mcp-gmail:9100``. Defaults to the bind
38
+ address, which is right on a workstation and wrong behind a service
39
+ name, so a deployment that has one should say it.
40
+ """
41
+
42
+ host: str = _LOOPBACK
43
+ port: int = 9100
44
+ public_host: str = ""
45
+
46
+ def __post_init__(self) -> None:
47
+ if not self.public_host:
48
+ object.__setattr__(self, "public_host", self._derived_public_host())
49
+
50
+ def _derived_public_host(self) -> str:
51
+ """Guess the authority when the deployment did not name one.
52
+
53
+ A wildcard bind falls back to loopback rather than to itself: guessing wrong
54
+ towards something unusable is better than minting an authority that resolves
55
+ nowhere and silently ends up in an OAuth resource identifier.
56
+
57
+ An IPv6 literal is bracketed, because ``::1:9100`` is not an authority - the
58
+ colons of the address and the colon of the port are indistinguishable, and
59
+ both a ``Host`` header and a URL need ``[::1]:9100``.
60
+ """
61
+ if self.host in _WILDCARDS:
62
+ return f"{_LOOPBACK}:{self.port}"
63
+
64
+ host = f"[{self.host}]" if _is_ipv6_literal(self.host) else self.host
65
+ return f"{host}:{self.port}"
66
+
67
+
68
+ def _is_ipv6_literal(host: str) -> bool:
69
+ """Whether a bind address is a bare IPv6 literal needing brackets."""
70
+ return ":" in host and not host.startswith("[")
@@ -0,0 +1,54 @@
1
+ """OAuth 2.1 protected-resource metadata, so a client can find the issuer itself.
2
+
3
+ The MCP specification expects an HTTP server to behave as an OAuth 2.1 resource
4
+ server: answer a credential-less request with a ``WWW-Authenticate`` header naming
5
+ where its metadata lives, and publish that metadata at a well-known path (RFC 9728).
6
+
7
+ That is the whole difference between a server a generic MCP client - VS Code,
8
+ Claude Desktop - can connect to by URL alone, and one that needs a human to be told
9
+ out of band which realm to authenticate against.
10
+
11
+ The metadata is published **unauthenticated**, necessarily: a client cannot present
12
+ a token before learning where to obtain one. It discloses only what a client needs
13
+ to start the flow, and nothing about the tools behind it.
14
+
15
+ Only published in JWT mode. A server authenticating an API key does not implement
16
+ an OAuth flow, and advertising one would be a lie a client would then act on.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from dataclasses import dataclass
22
+
23
+ PROTECTED_RESOURCE_PATH = "/.well-known/oauth-protected-resource"
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class ProtectedResource:
28
+ """What this server is, and which authorization server issues for it.
29
+
30
+ Args:
31
+ resource_url: The canonical URL of this MCP server, which is also its RFC
32
+ 8707 resource indicator - the audience a token must carry.
33
+ issuer: The authorization server tokens come from.
34
+ scopes: Scopes a client should ask for, when the deployment requires any.
35
+ """
36
+
37
+ resource_url: str
38
+ issuer: str
39
+ scopes: tuple[str, ...] = ()
40
+
41
+ def metadata(self) -> dict[str, object]:
42
+ """The RFC 9728 document."""
43
+ document: dict[str, object] = {
44
+ "resource": self.resource_url,
45
+ "authorization_servers": [self.issuer],
46
+ "bearer_methods_supported": ["header"],
47
+ }
48
+ if self.scopes:
49
+ document["scopes_supported"] = list(self.scopes)
50
+ return document
51
+
52
+ def challenge(self) -> str:
53
+ """The ``WWW-Authenticate`` value that points a client at the metadata."""
54
+ return f'Bearer resource_metadata="{self.resource_url}{PROTECTED_RESOURCE_PATH}"'
@@ -0,0 +1,28 @@
1
+ """Failures that stop an MCP server from being served at all.
2
+
3
+ Both of these are deliberately fatal, and both exist because the alternative is a
4
+ server that runs and looks healthy while doing the wrong thing. A process that
5
+ refuses to start is a cheap, loud failure somebody fixes in a minute; a process
6
+ serving its tools to an unauthenticated network, or answering every real request
7
+ with 421 behind a green health probe, is neither cheap nor loud.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+
13
+ class McpServerError(RuntimeError):
14
+ """Base of the failures raised while standing an MCP server up."""
15
+
16
+
17
+ class McpServerConfigurationError(McpServerError):
18
+ """Raised when the server cannot be served safely as configured."""
19
+
20
+
21
+ class McpServerUnreachableError(McpServerError):
22
+ """Raised when the server would refuse the callers it was deployed for.
23
+
24
+ Found the hard way: ``FastMCP`` derives its DNS-rebinding allow-list from the
25
+ bind address at construction and never revisits it, so a server built with the
26
+ default address answers every request carrying a service name in ``Host`` with
27
+ 421 - after authentication, before any tool, with the health probe still green.
28
+ """
@@ -0,0 +1,220 @@
1
+ """Reading an MCP server's authentication out of the environment.
2
+
3
+ The schemes live in :mod:`ygo74.agent_runtime.domains.auth`; this is only the
4
+ mapping from environment variables onto them. It sits in the library rather than in
5
+ each server because two servers in one deployment growing two different answers to
6
+ "who may call me" is how a weaker one appears without anybody deciding it should.
7
+
8
+ The prefix is what distinguishes them - ``MAIL_MCP_``, ``WIKI_MCP_`` - exactly as
9
+ ``AgentHttpSettings.from_env`` distinguishes two agents in one process.
10
+
11
+ Variables, for a prefix of ``MAIL_MCP_``:
12
+
13
+ ``MAIL_MCP_AUTH_MODE``
14
+ ``none``, ``api_key`` or ``jwt``.
15
+ ``MAIL_MCP_HTTP_TOKEN``
16
+ The shared secret, for ``api_key``.
17
+ ``MAIL_MCP_OIDC_ISSUER`` and ``MAIL_MCP_OIDC_AUDIENCE``
18
+ The issuer to validate against, for ``jwt``.
19
+ ``MAIL_MCP_RESOURCE_URL``
20
+ What the server calls itself as an OAuth resource, required for ``jwt``.
21
+
22
+ **The mode may be inferred, but never inferred as anonymous.** A deployment that set
23
+ a token has said something unambiguous, and breaking every running container to make
24
+ it say the same thing twice would buy nothing. A deployment that set nothing has said
25
+ nothing, and gets a refusal rather than an open port.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import os
31
+ from dataclasses import dataclass
32
+ from typing import Self
33
+
34
+ from ygo74.agent_runtime.domains.auth.apikey_authenticator import (
35
+ StaticApiKeyUserResolver,
36
+ )
37
+ from ygo74.agent_runtime.domains.auth.auth_context import ResolvedUser
38
+ from ygo74.agent_runtime.domains.auth.authentication_policy import (
39
+ AuthenticationConfigurationError,
40
+ AuthenticationMode,
41
+ AuthenticationPolicy,
42
+ )
43
+ from ygo74.agent_runtime.domains.auth.jwt_authenticator import (
44
+ DiscoveredJwksKeyResolver,
45
+ JwksKeyResolver,
46
+ JwtKeyResolver,
47
+ JwtValidationConfig,
48
+ )
49
+
50
+ MODE_SUFFIX = "AUTH_MODE"
51
+ TOKEN_SUFFIX = "HTTP_TOKEN"
52
+ ISSUER_SUFFIX = "OIDC_ISSUER"
53
+ AUDIENCE_SUFFIX = "OIDC_AUDIENCE"
54
+ RESOURCE_SUFFIX = "RESOURCE_URL"
55
+ JWKS_SUFFIX = "JWKS_URL"
56
+ ROLES_CLAIM_SUFFIX = "ROLES_CLAIM_PATH"
57
+
58
+ # Symmetric algorithms are refused: HS256 would mean the server holds the same key
59
+ # that signs tokens, which turns a resource server into an issuer by accident.
60
+ ASYMMETRIC_ALGORITHMS = ("RS256", "RS384", "RS512", "ES256", "ES384")
61
+
62
+
63
+ @dataclass(frozen=True, slots=True)
64
+ class McpServerAuthentication:
65
+ """The authentication an MCP server was configured with."""
66
+
67
+ policy: AuthenticationPolicy
68
+ resource_url: str = ""
69
+
70
+ @classmethod
71
+ def from_env(
72
+ cls,
73
+ prefix: str,
74
+ *,
75
+ caller_id: str,
76
+ environment: dict[str, str] | None = None,
77
+ ) -> Self:
78
+ """Read one server's authentication from prefixed environment variables.
79
+
80
+ Args:
81
+ prefix: What distinguishes this server's variables - ``MAIL_MCP_``.
82
+ caller_id: Who the shared secret authenticates, in ``api_key`` mode. A
83
+ deployment, not a person: the secret says "you are the agent I was
84
+ deployed with" and nothing about whose data is read.
85
+ environment: Read instead of the process environment, for tests.
86
+
87
+ Raises:
88
+ AuthenticationConfigurationError: nothing was configured, or what was
89
+ configured is incomplete. Always fatal: a server that started anyway
90
+ would be its tools on an open port, and the only sign of it would be
91
+ the absence of a line in a log.
92
+ """
93
+ source = environment if environment is not None else dict(os.environ)
94
+ names = _Names(prefix)
95
+ mode = cls._mode(source, names)
96
+
97
+ if mode is AuthenticationMode.NONE:
98
+ return cls(AuthenticationPolicy.anonymous())
99
+
100
+ if mode is AuthenticationMode.API_KEY:
101
+ return cls(cls._api_key_policy(source, names, caller_id))
102
+
103
+ return cls(cls._jwt_policy(source, names), _required(source, names.resource, names.mode, "jwt"))
104
+
105
+ @staticmethod
106
+ def _mode(source: dict[str, str], names: _Names) -> AuthenticationMode:
107
+ """Which mode was asked for, inferring a scheme but never inferring silence."""
108
+ declared = source.get(names.mode, "").strip()
109
+ if declared:
110
+ return AuthenticationMode.named(declared)
111
+
112
+ if source.get(names.token, "").strip():
113
+ # Unambiguous: a token was configured, so a token is what is checked.
114
+ return AuthenticationMode.API_KEY
115
+
116
+ if source.get(names.issuer, "").strip():
117
+ return AuthenticationMode.JWT
118
+
119
+ raise AuthenticationConfigurationError(
120
+ f"serving over HTTP requires {names.mode}: this process holds a credential for the "
121
+ "system behind it, and an unauthenticated port would hand that system to anything "
122
+ f"that can reach it. Set {names.mode}=api_key with {names.token}, or {names.mode}=jwt "
123
+ f"with {names.issuer}, or {names.mode}=none if serving everyone really is the intent"
124
+ )
125
+
126
+ @staticmethod
127
+ def _api_key_policy(source: dict[str, str], names: _Names, caller_id: str) -> AuthenticationPolicy:
128
+ """Authenticate a shared secret carried in an Authorization header."""
129
+ secret = source.get(names.token, "").strip()
130
+ if not secret:
131
+ raise AuthenticationConfigurationError(
132
+ f"{names.mode} is 'api_key' but {names.token} is empty: "
133
+ "there is nothing to check a caller against"
134
+ )
135
+
136
+ return AuthenticationPolicy.api_key(
137
+ StaticApiKeyUserResolver({secret: ResolvedUser(user_id=caller_id)}),
138
+ header_name="authorization",
139
+ scheme="Bearer",
140
+ )
141
+
142
+ @staticmethod
143
+ def _jwt_policy(source: dict[str, str], names: _Names) -> AuthenticationPolicy:
144
+ """Validate tokens against the configured issuer.
145
+
146
+ The signing keys are *discovered* rather than derived. Appending a path to
147
+ the issuer only works for one provider; asking the issuer works for all of
148
+ them. An explicit ``<prefix>JWKS_URL`` still wins, because an operator who
149
+ names a URL has a reason.
150
+
151
+ Without a key resolver the authenticator refuses every token before it ever
152
+ reaches a signature check - a server that publishes discovery metadata,
153
+ sends a client to the right realm, and then answers 401 to the valid token
154
+ it comes back with. It fails closed, which is why nothing catches it except
155
+ driving a real token through.
156
+ """
157
+ issuer = _required(source, names.issuer, names.mode, "jwt")
158
+ audience = source.get(names.audience, "").strip()
159
+ explicit_jwks = source.get(names.jwks, "").strip()
160
+ resolver: JwtKeyResolver = (
161
+ JwksKeyResolver(jwks_url=explicit_jwks)
162
+ if explicit_jwks
163
+ else DiscoveredJwksKeyResolver(issuer=issuer)
164
+ )
165
+
166
+ return AuthenticationPolicy.jwt(
167
+ JwtValidationConfig(
168
+ issuer=issuer,
169
+ audience=audience or None,
170
+ allowed_algorithms=ASYMMETRIC_ALGORITHMS,
171
+ key_resolver=resolver,
172
+ roles_claim_path=source.get(names.roles_claim, "").strip() or None,
173
+ )
174
+ )
175
+
176
+
177
+ @dataclass(frozen=True, slots=True)
178
+ class _Names:
179
+ """The variable names of one prefix."""
180
+
181
+ prefix: str
182
+
183
+ @property
184
+ def mode(self) -> str:
185
+ return f"{self.prefix}{MODE_SUFFIX}"
186
+
187
+ @property
188
+ def token(self) -> str:
189
+ return f"{self.prefix}{TOKEN_SUFFIX}"
190
+
191
+ @property
192
+ def issuer(self) -> str:
193
+ return f"{self.prefix}{ISSUER_SUFFIX}"
194
+
195
+ @property
196
+ def audience(self) -> str:
197
+ return f"{self.prefix}{AUDIENCE_SUFFIX}"
198
+
199
+ @property
200
+ def resource(self) -> str:
201
+ return f"{self.prefix}{RESOURCE_SUFFIX}"
202
+
203
+ @property
204
+ def jwks(self) -> str:
205
+ return f"{self.prefix}{JWKS_SUFFIX}"
206
+
207
+ @property
208
+ def roles_claim(self) -> str:
209
+ return f"{self.prefix}{ROLES_CLAIM_SUFFIX}"
210
+
211
+
212
+ def _required(source: dict[str, str], variable: str, mode_variable: str, mode: str) -> str:
213
+ """Read a variable the chosen mode cannot work without."""
214
+ value = source.get(variable, "").strip()
215
+ if not value:
216
+ raise AuthenticationConfigurationError(
217
+ f"{mode_variable} is {mode!r} but {variable} is empty: "
218
+ "a resource server that cannot name itself or its issuer cannot be discovered"
219
+ )
220
+ return value
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.4
2
+ Name: ygo74-agent-runtime-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server hosting for the ygo74 agent runtime: transport, authentication and health
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: ygo74-agent-runtime-security==0.1.0
7
+ Requires-Dist: pydantic>=2.7
8
+ Provides-Extra: http
9
+ Requires-Dist: mcp<2,>=1.24; extra == "http"
10
+ Requires-Dist: starlette>=0.37; extra == "http"
11
+ Requires-Dist: uvicorn[standard]>=0.30; extra == "http"
12
+ Requires-Dist: httpx>=0.27; extra == "http"
@@ -0,0 +1,12 @@
1
+ pyproject.toml
2
+ ygo74/agent_runtime/domains/mcpserver/host.py
3
+ ygo74/agent_runtime/domains/mcpserver/http_binding.py
4
+ ygo74/agent_runtime/domains/mcpserver/protected_resource.py
5
+ ygo74/agent_runtime/domains/mcpserver/py.typed
6
+ ygo74/agent_runtime/domains/mcpserver/server_errors.py
7
+ ygo74/agent_runtime/domains/mcpserver/settings.py
8
+ ygo74_agent_runtime_mcp.egg-info/PKG-INFO
9
+ ygo74_agent_runtime_mcp.egg-info/SOURCES.txt
10
+ ygo74_agent_runtime_mcp.egg-info/dependency_links.txt
11
+ ygo74_agent_runtime_mcp.egg-info/requires.txt
12
+ ygo74_agent_runtime_mcp.egg-info/top_level.txt
@@ -0,0 +1,8 @@
1
+ ygo74-agent-runtime-security==0.1.0
2
+ pydantic>=2.7
3
+
4
+ [http]
5
+ mcp<2,>=1.24
6
+ starlette>=0.37
7
+ uvicorn[standard]>=0.30
8
+ httpx>=0.27