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.
- ygo74_agent_runtime_mcp-0.1.0/PKG-INFO +12 -0
- ygo74_agent_runtime_mcp-0.1.0/pyproject.toml +46 -0
- ygo74_agent_runtime_mcp-0.1.0/setup.cfg +4 -0
- ygo74_agent_runtime_mcp-0.1.0/ygo74/agent_runtime/domains/mcpserver/host.py +319 -0
- ygo74_agent_runtime_mcp-0.1.0/ygo74/agent_runtime/domains/mcpserver/http_binding.py +70 -0
- ygo74_agent_runtime_mcp-0.1.0/ygo74/agent_runtime/domains/mcpserver/protected_resource.py +54 -0
- ygo74_agent_runtime_mcp-0.1.0/ygo74/agent_runtime/domains/mcpserver/py.typed +0 -0
- ygo74_agent_runtime_mcp-0.1.0/ygo74/agent_runtime/domains/mcpserver/server_errors.py +28 -0
- ygo74_agent_runtime_mcp-0.1.0/ygo74/agent_runtime/domains/mcpserver/settings.py +220 -0
- ygo74_agent_runtime_mcp-0.1.0/ygo74_agent_runtime_mcp.egg-info/PKG-INFO +12 -0
- ygo74_agent_runtime_mcp-0.1.0/ygo74_agent_runtime_mcp.egg-info/SOURCES.txt +12 -0
- ygo74_agent_runtime_mcp-0.1.0/ygo74_agent_runtime_mcp.egg-info/dependency_links.txt +1 -0
- ygo74_agent_runtime_mcp-0.1.0/ygo74_agent_runtime_mcp.egg-info/requires.txt +8 -0
- ygo74_agent_runtime_mcp-0.1.0/ygo74_agent_runtime_mcp.egg-info/top_level.txt +1 -0
|
@@ -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,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}"'
|
|
File without changes
|
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ygo74
|