lightnow-proxy 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lightnow_proxy/__init__.py +1 -0
- lightnow_proxy/app.py +278 -0
- lightnow_proxy/auth.py +160 -0
- lightnow_proxy/capture.py +200 -0
- lightnow_proxy/config.py +166 -0
- lightnow_proxy/main.py +102 -0
- lightnow_proxy/registry.py +575 -0
- lightnow_proxy/request_context.py +12 -0
- lightnow_proxy/router.py +694 -0
- lightnow_proxy/upstream.py +94 -0
- lightnow_proxy-1.0.0.dist-info/METADATA +135 -0
- lightnow_proxy-1.0.0.dist-info/RECORD +16 -0
- lightnow_proxy-1.0.0.dist-info/WHEEL +5 -0
- lightnow_proxy-1.0.0.dist-info/entry_points.txt +2 -0
- lightnow_proxy-1.0.0.dist-info/licenses/LICENSE +201 -0
- lightnow_proxy-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|
lightnow_proxy/app.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from contextlib import AsyncExitStack, asynccontextmanager
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Any, AsyncIterator
|
|
6
|
+
|
|
7
|
+
from mcp.server import Server
|
|
8
|
+
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
|
|
9
|
+
from mcp import types
|
|
10
|
+
from mcp.types import CallToolResult, ReadResourceResult, TextContent
|
|
11
|
+
from starlette.applications import Starlette
|
|
12
|
+
from starlette.datastructures import Headers
|
|
13
|
+
from starlette.requests import Request
|
|
14
|
+
from starlette.responses import JSONResponse, Response
|
|
15
|
+
from starlette.routing import Route
|
|
16
|
+
from starlette.types import Receive, Scope, Send
|
|
17
|
+
import structlog
|
|
18
|
+
|
|
19
|
+
from lightnow_proxy import __version__
|
|
20
|
+
from lightnow_proxy.auth import AuthError, TokenVerifier, has_required_group
|
|
21
|
+
from lightnow_proxy.config import ProxyConfig
|
|
22
|
+
from lightnow_proxy.request_context import current_principal
|
|
23
|
+
from lightnow_proxy.router import ToolRouter, ToolRoutingError
|
|
24
|
+
|
|
25
|
+
logger = structlog.get_logger("lightnow_proxy")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ProfileMCPApp:
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
profile_name: str,
|
|
32
|
+
config: ProxyConfig,
|
|
33
|
+
router: ToolRouter,
|
|
34
|
+
verifier: TokenVerifier,
|
|
35
|
+
):
|
|
36
|
+
self.profile_name = profile_name
|
|
37
|
+
self.config = config
|
|
38
|
+
self.router = router
|
|
39
|
+
self.verifier = verifier
|
|
40
|
+
self.server = self._build_server()
|
|
41
|
+
self.session_manager = StreamableHTTPSessionManager(
|
|
42
|
+
app=self.server,
|
|
43
|
+
json_response=False,
|
|
44
|
+
stateless=True,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def _build_server(self) -> Server:
|
|
48
|
+
server = Server(f"lightnow-proxy-{self.profile_name}", version=__version__)
|
|
49
|
+
|
|
50
|
+
@server.list_tools()
|
|
51
|
+
async def list_tools() -> list:
|
|
52
|
+
return await self.router.list_tools(self.profile_name)
|
|
53
|
+
|
|
54
|
+
@server.call_tool(validate_input=False)
|
|
55
|
+
async def call_tool(
|
|
56
|
+
name: str,
|
|
57
|
+
arguments: dict[str, Any] | None = None,
|
|
58
|
+
request_meta: dict[str, Any] | None = None,
|
|
59
|
+
) -> CallToolResult:
|
|
60
|
+
try:
|
|
61
|
+
return await self.router.call_tool(self.profile_name, name, arguments or {}, request_meta)
|
|
62
|
+
except ToolRoutingError as exc:
|
|
63
|
+
return CallToolResult(content=[TextContent(type="text", text=str(exc))], isError=True)
|
|
64
|
+
|
|
65
|
+
@server.list_resources()
|
|
66
|
+
async def list_resources() -> list:
|
|
67
|
+
return await self.router.list_resources(self.profile_name)
|
|
68
|
+
|
|
69
|
+
@server.list_resource_templates()
|
|
70
|
+
async def list_resource_templates() -> list:
|
|
71
|
+
return await self.router.list_resource_templates(self.profile_name)
|
|
72
|
+
|
|
73
|
+
@server.read_resource()
|
|
74
|
+
async def read_resource(uri: str) -> ReadResourceResult:
|
|
75
|
+
return await self.router.read_resource(self.profile_name, uri)
|
|
76
|
+
|
|
77
|
+
register_unvalidated_call_tool_handler(server, call_tool)
|
|
78
|
+
return server
|
|
79
|
+
|
|
80
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
81
|
+
profile = self.config.profiles[self.profile_name]
|
|
82
|
+
try:
|
|
83
|
+
principal = await self.verifier.verify_headers(Headers(scope=scope))
|
|
84
|
+
if not has_required_group(principal, profile.required_groups):
|
|
85
|
+
logger.warning(
|
|
86
|
+
"mcp_profile_forbidden",
|
|
87
|
+
profile=self.profile_name,
|
|
88
|
+
subject=principal.subject,
|
|
89
|
+
username=principal.username,
|
|
90
|
+
required_groups=profile.required_groups,
|
|
91
|
+
)
|
|
92
|
+
await forbidden(scope, receive, send)
|
|
93
|
+
return
|
|
94
|
+
scope.setdefault("state", {})
|
|
95
|
+
scope["state"]["principal"] = principal
|
|
96
|
+
except AuthError as exc:
|
|
97
|
+
logger.warning("mcp_profile_unauthorized", profile=self.profile_name, reason=str(exc))
|
|
98
|
+
await unauthorized(scope, receive, send, str(exc))
|
|
99
|
+
return
|
|
100
|
+
except Exception as exc:
|
|
101
|
+
logger.warning("mcp_profile_auth_failed", profile=self.profile_name, reason=exc.__class__.__name__)
|
|
102
|
+
await unauthorized(scope, receive, send, "invalid token")
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
principal_token = current_principal.set(principal)
|
|
106
|
+
try:
|
|
107
|
+
await self.session_manager.handle_request(scope, receive, send)
|
|
108
|
+
finally:
|
|
109
|
+
current_principal.reset(principal_token)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class LocalProxyMCPApp:
|
|
113
|
+
def __init__(
|
|
114
|
+
self,
|
|
115
|
+
config: ProxyConfig,
|
|
116
|
+
router: ToolRouter,
|
|
117
|
+
):
|
|
118
|
+
self.config = config
|
|
119
|
+
self.router = router
|
|
120
|
+
self.profile_name = config.local_proxy.profile
|
|
121
|
+
self.server = self._build_server()
|
|
122
|
+
self.session_manager = StreamableHTTPSessionManager(
|
|
123
|
+
app=self.server,
|
|
124
|
+
json_response=False,
|
|
125
|
+
stateless=True,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def _build_server(self) -> Server:
|
|
129
|
+
server = Server("lightnow-local-proxy", version=__version__)
|
|
130
|
+
|
|
131
|
+
@server.list_tools()
|
|
132
|
+
async def list_tools() -> list:
|
|
133
|
+
return await self.router.list_tools(self.profile_name)
|
|
134
|
+
|
|
135
|
+
@server.call_tool(validate_input=False)
|
|
136
|
+
async def call_tool(
|
|
137
|
+
name: str,
|
|
138
|
+
arguments: dict[str, Any] | None = None,
|
|
139
|
+
request_meta: dict[str, Any] | None = None,
|
|
140
|
+
) -> CallToolResult:
|
|
141
|
+
try:
|
|
142
|
+
return await self.router.call_tool(self.profile_name, name, arguments or {}, request_meta)
|
|
143
|
+
except ToolRoutingError as exc:
|
|
144
|
+
return CallToolResult(content=[TextContent(type="text", text=str(exc))], isError=True)
|
|
145
|
+
|
|
146
|
+
@server.list_resources()
|
|
147
|
+
async def list_resources() -> list:
|
|
148
|
+
return await self.router.list_resources(self.profile_name)
|
|
149
|
+
|
|
150
|
+
@server.list_resource_templates()
|
|
151
|
+
async def list_resource_templates() -> list:
|
|
152
|
+
return await self.router.list_resource_templates(self.profile_name)
|
|
153
|
+
|
|
154
|
+
@server.read_resource()
|
|
155
|
+
async def read_resource(uri: str) -> ReadResourceResult:
|
|
156
|
+
return await self.router.read_resource(self.profile_name, uri)
|
|
157
|
+
|
|
158
|
+
register_unvalidated_call_tool_handler(server, call_tool)
|
|
159
|
+
return server
|
|
160
|
+
|
|
161
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
162
|
+
await self.session_manager.handle_request(scope, receive, send)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
async def unauthorized(scope: Scope, receive: Receive, send: Send, detail: str) -> None:
|
|
166
|
+
response = JSONResponse(
|
|
167
|
+
{"error": "unauthorized", "detail": detail},
|
|
168
|
+
status_code=401,
|
|
169
|
+
headers={"WWW-Authenticate": 'Bearer error="invalid_token"'},
|
|
170
|
+
)
|
|
171
|
+
await response(scope, receive, send)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
async def forbidden(scope: Scope, receive: Receive, send: Send) -> None:
|
|
175
|
+
response = JSONResponse({"error": "forbidden"}, status_code=403)
|
|
176
|
+
await response(scope, receive, send)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
async def healthz(_request: Request) -> Response:
|
|
180
|
+
return JSONResponse({"status": "ok"})
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
async def status(_request: Request, config: ProxyConfig) -> Response:
|
|
184
|
+
"""Return non-secret Local Proxy runtime status for local health checks."""
|
|
185
|
+
mode = "local_proxy" if config.local_proxy.enabled else "profile_proxy"
|
|
186
|
+
return JSONResponse(
|
|
187
|
+
{
|
|
188
|
+
"status": "ok",
|
|
189
|
+
"mode": mode,
|
|
190
|
+
"runner": {
|
|
191
|
+
"name": config.local_proxy.runner_name,
|
|
192
|
+
"version": config.local_proxy.runner_version,
|
|
193
|
+
},
|
|
194
|
+
"local_proxy": {
|
|
195
|
+
"enabled": config.local_proxy.enabled,
|
|
196
|
+
"profile": config.local_proxy.profile if config.local_proxy.enabled else None,
|
|
197
|
+
"path": config.local_proxy.path if config.local_proxy.enabled else None,
|
|
198
|
+
"sync_from_lightnow": config.local_proxy.sync_from_lightnow,
|
|
199
|
+
"client_name": config.local_proxy.client_name,
|
|
200
|
+
"client_version": config.local_proxy.client_version,
|
|
201
|
+
"client_transport": config.local_proxy.client_transport,
|
|
202
|
+
"telemetry_enabled": config.local_proxy.telemetry_enabled,
|
|
203
|
+
"policy_mode": config.local_proxy.policy_mode,
|
|
204
|
+
"allow_unmanaged_client_servers": config.local_proxy.allow_unmanaged_client_servers,
|
|
205
|
+
},
|
|
206
|
+
"registry_api": {
|
|
207
|
+
"enabled": bool(config.registry_api and config.registry_api.enabled),
|
|
208
|
+
"use_cli_session": bool(config.registry_api and config.registry_api.use_cli_session),
|
|
209
|
+
},
|
|
210
|
+
"profiles": sorted(config.profiles.keys()),
|
|
211
|
+
"static_upstreams": sorted(config.upstreams.keys()),
|
|
212
|
+
}
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def create_app(config: ProxyConfig, router: ToolRouter | None = None, verifier: TokenVerifier | None = None) -> Starlette:
|
|
217
|
+
logging.basicConfig(level=logging.INFO)
|
|
218
|
+
structlog.configure(
|
|
219
|
+
wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
|
|
220
|
+
processors=[
|
|
221
|
+
structlog.processors.add_log_level,
|
|
222
|
+
structlog.processors.TimeStamper(fmt="iso", utc=True),
|
|
223
|
+
structlog.processors.JSONRenderer(),
|
|
224
|
+
],
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
router = router or ToolRouter(config)
|
|
228
|
+
verifier = verifier or TokenVerifier(config.auth)
|
|
229
|
+
local_proxy_app = LocalProxyMCPApp(config, router) if config.local_proxy.enabled else None
|
|
230
|
+
profile_apps = {}
|
|
231
|
+
if local_proxy_app is None:
|
|
232
|
+
profile_apps = {
|
|
233
|
+
profile_name: ProfileMCPApp(profile_name, config, router, verifier)
|
|
234
|
+
for profile_name in sorted(config.profiles.keys())
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async def status_endpoint(request: Request) -> Response:
|
|
238
|
+
return await status(request, config)
|
|
239
|
+
|
|
240
|
+
routes = [
|
|
241
|
+
Route("/healthz", healthz, methods=["GET"]),
|
|
242
|
+
Route("/status", status_endpoint, methods=["GET"]),
|
|
243
|
+
]
|
|
244
|
+
if local_proxy_app is not None:
|
|
245
|
+
routes.append(Route(config.local_proxy.path, endpoint=local_proxy_app))
|
|
246
|
+
for profile_name, profile_app in profile_apps.items():
|
|
247
|
+
routes.append(Route(f"/profiles/{profile_name}/mcp", endpoint=profile_app))
|
|
248
|
+
|
|
249
|
+
@asynccontextmanager
|
|
250
|
+
async def lifespan(_app: Starlette) -> AsyncIterator[None]:
|
|
251
|
+
async with AsyncExitStack() as stack:
|
|
252
|
+
if local_proxy_app is not None:
|
|
253
|
+
await stack.enter_async_context(local_proxy_app.session_manager.run())
|
|
254
|
+
await emit_lifecycle_event(router, "runner_started")
|
|
255
|
+
for profile_app in profile_apps.values():
|
|
256
|
+
await stack.enter_async_context(profile_app.session_manager.run())
|
|
257
|
+
try:
|
|
258
|
+
yield
|
|
259
|
+
finally:
|
|
260
|
+
if local_proxy_app is not None:
|
|
261
|
+
await emit_lifecycle_event(router, "runner_stopped")
|
|
262
|
+
|
|
263
|
+
return Starlette(routes=routes, lifespan=lifespan)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
async def emit_lifecycle_event(router: Any, event_type: str) -> None:
|
|
267
|
+
emitter = getattr(router, "emit_lifecycle_event", None)
|
|
268
|
+
if emitter is not None:
|
|
269
|
+
await emitter(event_type)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def register_unvalidated_call_tool_handler(server: Server, handler_func) -> None:
|
|
273
|
+
async def handler(req: types.CallToolRequest):
|
|
274
|
+
request_meta = req.params.meta.model_dump(mode="json", by_alias=True, exclude_none=True) if req.params.meta else None
|
|
275
|
+
result = await handler_func(req.params.name, req.params.arguments or {}, request_meta)
|
|
276
|
+
return types.ServerResult(result)
|
|
277
|
+
|
|
278
|
+
server.request_handlers[types.CallToolRequest] = handler
|
lightnow_proxy/auth.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
import jwt
|
|
8
|
+
from jwt import PyJWTError
|
|
9
|
+
from jwt import PyJWKClient
|
|
10
|
+
from starlette.datastructures import Headers
|
|
11
|
+
|
|
12
|
+
from lightnow_proxy.config import AuthConfig
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AuthError(Exception):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Principal:
|
|
20
|
+
def __init__(self, subject: str, username: str | None, groups: set[str], claims: dict[str, Any]):
|
|
21
|
+
self.subject = subject
|
|
22
|
+
self.username = username
|
|
23
|
+
self.groups = groups
|
|
24
|
+
self.claims = claims
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TokenVerifier:
|
|
28
|
+
def __init__(self, config: AuthConfig):
|
|
29
|
+
self.config = config
|
|
30
|
+
self._jwks_client: PyJWKClient | None = None
|
|
31
|
+
self._jwks_client_expires_at = 0.0
|
|
32
|
+
|
|
33
|
+
async def verify_headers(self, headers: Headers) -> Principal:
|
|
34
|
+
if not self.config.enabled:
|
|
35
|
+
return Principal(subject="anonymous", username="anonymous", groups={"*"}, claims={})
|
|
36
|
+
|
|
37
|
+
auth_header = headers.get("authorization")
|
|
38
|
+
if not auth_header or not auth_header.lower().startswith("bearer "):
|
|
39
|
+
raise AuthError("missing bearer token")
|
|
40
|
+
|
|
41
|
+
token = auth_header.split(" ", 1)[1].strip()
|
|
42
|
+
if token in self.config.dev_bearer_tokens:
|
|
43
|
+
return self._principal_from_claims(self.config.dev_bearer_tokens[token])
|
|
44
|
+
|
|
45
|
+
claims = await self._decode_jwt(token)
|
|
46
|
+
return self._principal_from_claims(claims)
|
|
47
|
+
|
|
48
|
+
async def _decode_jwt(self, token: str) -> dict[str, Any]:
|
|
49
|
+
client = await self._jwks()
|
|
50
|
+
signing_key = client.get_signing_key_from_jwt(token)
|
|
51
|
+
try:
|
|
52
|
+
return self._decode_with_audience_or_authorized_party(token, signing_key.key)
|
|
53
|
+
except PyJWTError as exc:
|
|
54
|
+
raise AuthError(f"{exc.__class__.__name__}: {exc}") from exc
|
|
55
|
+
|
|
56
|
+
def _decode_with_audience_or_authorized_party(self, token: str, signing_key: Any) -> dict[str, Any]:
|
|
57
|
+
audiences = configured_audiences(self.config.audience)
|
|
58
|
+
if not audiences:
|
|
59
|
+
return jwt.decode(
|
|
60
|
+
token,
|
|
61
|
+
signing_key,
|
|
62
|
+
algorithms=["RS256", "RS384", "RS512"],
|
|
63
|
+
issuer=self.config.issuer,
|
|
64
|
+
options={"verify_aud": False},
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
unverified_claims = jwt.decode(token, options={"verify_signature": False})
|
|
68
|
+
if unverified_claims.get("aud"):
|
|
69
|
+
try:
|
|
70
|
+
return jwt.decode(
|
|
71
|
+
token,
|
|
72
|
+
signing_key,
|
|
73
|
+
algorithms=["RS256", "RS384", "RS512"],
|
|
74
|
+
audience=audiences,
|
|
75
|
+
issuer=self.config.issuer,
|
|
76
|
+
)
|
|
77
|
+
except jwt.InvalidAudienceError:
|
|
78
|
+
if unverified_claims.get("azp") not in audiences:
|
|
79
|
+
raise
|
|
80
|
+
elif unverified_claims.get("azp") not in audiences:
|
|
81
|
+
raise jwt.MissingRequiredClaimError("aud")
|
|
82
|
+
|
|
83
|
+
claims = jwt.decode(
|
|
84
|
+
token,
|
|
85
|
+
signing_key,
|
|
86
|
+
algorithms=["RS256", "RS384", "RS512"],
|
|
87
|
+
issuer=self.config.issuer,
|
|
88
|
+
options={"verify_aud": False},
|
|
89
|
+
)
|
|
90
|
+
if claims.get("azp") not in audiences:
|
|
91
|
+
raise jwt.InvalidAudienceError("authorized party is not allowed")
|
|
92
|
+
return claims
|
|
93
|
+
|
|
94
|
+
async def _jwks(self) -> PyJWKClient:
|
|
95
|
+
now = time.time()
|
|
96
|
+
if self._jwks_client and now < self._jwks_client_expires_at:
|
|
97
|
+
return self._jwks_client
|
|
98
|
+
|
|
99
|
+
async with httpx.AsyncClient(timeout=10) as client:
|
|
100
|
+
response = await client.get(f"{self.config.issuer.rstrip('/')}/.well-known/openid-configuration")
|
|
101
|
+
response.raise_for_status()
|
|
102
|
+
metadata = response.json()
|
|
103
|
+
|
|
104
|
+
jwks_uri = metadata.get("jwks_uri")
|
|
105
|
+
if not isinstance(jwks_uri, str) or not jwks_uri:
|
|
106
|
+
raise AuthError("issuer metadata does not contain jwks_uri")
|
|
107
|
+
|
|
108
|
+
self._jwks_client = PyJWKClient(jwks_uri)
|
|
109
|
+
self._jwks_client_expires_at = now + self.config.jwks_cache_seconds
|
|
110
|
+
return self._jwks_client
|
|
111
|
+
|
|
112
|
+
def _principal_from_claims(self, claims: dict[str, Any]) -> Principal:
|
|
113
|
+
subject = str(claims.get("sub") or "")
|
|
114
|
+
if not subject:
|
|
115
|
+
raise AuthError("token does not contain sub")
|
|
116
|
+
|
|
117
|
+
raw_groups = claims.get(self.config.groups_claim, [])
|
|
118
|
+
groups = normalize_groups(raw_groups)
|
|
119
|
+
username = claims.get("preferred_username") or claims.get("email")
|
|
120
|
+
return Principal(subject=subject, username=str(username) if username else None, groups=groups, claims=claims)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def normalize_groups(raw_groups: Any) -> set[str]:
|
|
124
|
+
if raw_groups is None:
|
|
125
|
+
return set()
|
|
126
|
+
if isinstance(raw_groups, str):
|
|
127
|
+
raw_iterable = [raw_groups]
|
|
128
|
+
elif isinstance(raw_groups, list):
|
|
129
|
+
raw_iterable = raw_groups
|
|
130
|
+
else:
|
|
131
|
+
return set()
|
|
132
|
+
|
|
133
|
+
groups: set[str] = set()
|
|
134
|
+
for item in raw_iterable:
|
|
135
|
+
if not isinstance(item, str):
|
|
136
|
+
continue
|
|
137
|
+
normalized = item.strip()
|
|
138
|
+
if not normalized:
|
|
139
|
+
continue
|
|
140
|
+
groups.add(normalized)
|
|
141
|
+
groups.add(normalized.strip("/"))
|
|
142
|
+
groups.add(normalized.rsplit("/", 1)[-1])
|
|
143
|
+
return groups
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def configured_audiences(raw_audience: str | list[str] | None) -> list[str]:
|
|
147
|
+
if raw_audience is None:
|
|
148
|
+
return []
|
|
149
|
+
if isinstance(raw_audience, str):
|
|
150
|
+
return [raw_audience]
|
|
151
|
+
return [audience for audience in raw_audience if audience]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def has_required_group(principal: Principal, required_groups: list[str]) -> bool:
|
|
155
|
+
if "*" in principal.groups:
|
|
156
|
+
return True
|
|
157
|
+
if not required_groups:
|
|
158
|
+
return True
|
|
159
|
+
wanted = {group.strip("/").rsplit("/", 1)[-1] for group in required_groups}
|
|
160
|
+
return bool(principal.groups.intersection(wanted))
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import UTC, datetime
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Callable
|
|
7
|
+
|
|
8
|
+
import anyio
|
|
9
|
+
from mcp.shared.message import SessionMessage
|
|
10
|
+
from mcp import types
|
|
11
|
+
|
|
12
|
+
SENSITIVE_KEY_FRAGMENTS = (
|
|
13
|
+
"authorization",
|
|
14
|
+
"api_key",
|
|
15
|
+
"apikey",
|
|
16
|
+
"access_token",
|
|
17
|
+
"refresh_token",
|
|
18
|
+
"token",
|
|
19
|
+
"password",
|
|
20
|
+
"secret",
|
|
21
|
+
"client_secret",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def capture_enabled(path: str | None) -> bool:
|
|
26
|
+
return bool(path and path.strip())
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def initialize_capture_file(path: str, config_path: str) -> None:
|
|
30
|
+
capture_path = Path(path).expanduser()
|
|
31
|
+
capture_path.parent.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
with capture_path.open("a", encoding="utf-8") as handle:
|
|
33
|
+
handle.write("LightNow Local Proxy MCP Protocol Capture\n")
|
|
34
|
+
handle.write("=========================================\n")
|
|
35
|
+
handle.write(f"started_at: {timestamp()}\n")
|
|
36
|
+
handle.write(f"config: {config_path}\n")
|
|
37
|
+
handle.write("redaction: secret-like keys are replaced with [REDACTED]\n\n")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
SessionMessageObserver = Callable[[SessionMessage], None]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def capture_read_stream(
|
|
44
|
+
read_stream,
|
|
45
|
+
capture_path: str | None = None,
|
|
46
|
+
on_session_message: SessionMessageObserver | None = None,
|
|
47
|
+
):
|
|
48
|
+
sender, receiver = anyio.create_memory_object_stream(0)
|
|
49
|
+
|
|
50
|
+
async def forward_messages() -> None:
|
|
51
|
+
async with sender:
|
|
52
|
+
async for item in read_stream:
|
|
53
|
+
if isinstance(item, SessionMessage):
|
|
54
|
+
if on_session_message is not None:
|
|
55
|
+
on_session_message(item)
|
|
56
|
+
if capture_path:
|
|
57
|
+
await write_capture(capture_path, describe_session_message(item))
|
|
58
|
+
await sender.send(item)
|
|
59
|
+
|
|
60
|
+
return receiver, forward_messages
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def write_capture(capture_path: str, block: str | None) -> None:
|
|
64
|
+
if not block:
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
def append() -> None:
|
|
68
|
+
with Path(capture_path).expanduser().open("a", encoding="utf-8") as handle:
|
|
69
|
+
handle.write(block)
|
|
70
|
+
handle.write("\n")
|
|
71
|
+
|
|
72
|
+
await anyio.to_thread.run_sync(append)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def describe_session_message(session_message: SessionMessage) -> str | None:
|
|
76
|
+
message = session_message.message.root
|
|
77
|
+
if isinstance(message, types.JSONRPCRequest):
|
|
78
|
+
return describe_request(message)
|
|
79
|
+
if isinstance(message, types.JSONRPCNotification):
|
|
80
|
+
return describe_notification(message)
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def initialize_client_context(session_message: SessionMessage) -> dict[str, Any] | None:
|
|
85
|
+
message = session_message.message.root
|
|
86
|
+
if not isinstance(message, types.JSONRPCRequest):
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
payload = message.model_dump(mode="json", by_alias=True, exclude_none=True)
|
|
90
|
+
if payload.get("method") != "initialize":
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
params = payload.get("params") if isinstance(payload.get("params"), dict) else {}
|
|
94
|
+
client_info = params.get("clientInfo") if isinstance(params.get("clientInfo"), dict) else {}
|
|
95
|
+
capabilities = params.get("capabilities") if isinstance(params.get("capabilities"), dict) else {}
|
|
96
|
+
return {
|
|
97
|
+
"name": client_info.get("name") if isinstance(client_info.get("name"), str) else None,
|
|
98
|
+
"version": client_info.get("version") if isinstance(client_info.get("version"), str) else None,
|
|
99
|
+
"capability_keys": sorted(str(key) for key in capabilities.keys()),
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def describe_request(message: types.JSONRPCRequest) -> str:
|
|
104
|
+
payload = message.model_dump(mode="json", by_alias=True, exclude_none=True)
|
|
105
|
+
method = str(payload.get("method") or "")
|
|
106
|
+
params = payload.get("params") if isinstance(payload.get("params"), dict) else {}
|
|
107
|
+
redacted_params = redact(params)
|
|
108
|
+
lines = [
|
|
109
|
+
f"[{timestamp()}] request {method}",
|
|
110
|
+
f"id: {payload.get('id')}",
|
|
111
|
+
f"params_bytes: {json_size(params)}",
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
if method == "initialize":
|
|
115
|
+
lines.extend(describe_initialize_params(redacted_params))
|
|
116
|
+
elif method == "tools/call":
|
|
117
|
+
lines.extend(describe_tool_call_params(redacted_params, params))
|
|
118
|
+
elif method in {"resources/read", "resources/list", "resources/templates/list", "tools/list"}:
|
|
119
|
+
lines.append("params:")
|
|
120
|
+
lines.append(pretty(redacted_params))
|
|
121
|
+
else:
|
|
122
|
+
lines.append("params:")
|
|
123
|
+
lines.append(pretty(redacted_params))
|
|
124
|
+
|
|
125
|
+
meta = redacted_params.get("_meta") if isinstance(redacted_params, dict) else None
|
|
126
|
+
if isinstance(meta, dict):
|
|
127
|
+
lines.append(f"meta_keys: {', '.join(sorted(meta.keys())) or '-'}")
|
|
128
|
+
|
|
129
|
+
return "\n".join(lines) + "\n"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def describe_notification(message: types.JSONRPCNotification) -> str:
|
|
133
|
+
payload = message.model_dump(mode="json", by_alias=True, exclude_none=True)
|
|
134
|
+
params = payload.get("params") if isinstance(payload.get("params"), dict) else {}
|
|
135
|
+
return "\n".join(
|
|
136
|
+
[
|
|
137
|
+
f"[{timestamp()}] notification {payload.get('method')}",
|
|
138
|
+
f"params_bytes: {json_size(params)}",
|
|
139
|
+
"params:",
|
|
140
|
+
pretty(redact(params)),
|
|
141
|
+
"",
|
|
142
|
+
]
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def describe_initialize_params(params: dict[str, Any]) -> list[str]:
|
|
147
|
+
client_info = params.get("clientInfo") if isinstance(params.get("clientInfo"), dict) else {}
|
|
148
|
+
capabilities = params.get("capabilities") if isinstance(params.get("capabilities"), dict) else {}
|
|
149
|
+
return [
|
|
150
|
+
f"protocol_version: {params.get('protocolVersion') or '-'}",
|
|
151
|
+
f"client_name: {client_info.get('name') or '-'}",
|
|
152
|
+
f"client_version: {client_info.get('version') or '-'}",
|
|
153
|
+
f"capability_keys: {', '.join(sorted(capabilities.keys())) or '-'}",
|
|
154
|
+
"params:",
|
|
155
|
+
pretty(params),
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def describe_tool_call_params(params: dict[str, Any], raw_params: dict[str, Any]) -> list[str]:
|
|
160
|
+
arguments = params.get("arguments") if isinstance(params.get("arguments"), dict) else {}
|
|
161
|
+
raw_arguments = raw_params.get("arguments") if isinstance(raw_params.get("arguments"), dict) else {}
|
|
162
|
+
return [
|
|
163
|
+
f"tool_name: {params.get('name') or '-'}",
|
|
164
|
+
f"argument_keys: {', '.join(sorted(arguments.keys())) or '-'}",
|
|
165
|
+
f"argument_bytes: {json_size(raw_arguments)}",
|
|
166
|
+
"params:",
|
|
167
|
+
pretty(params),
|
|
168
|
+
]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def redact(value: Any) -> Any:
|
|
172
|
+
if isinstance(value, dict):
|
|
173
|
+
redacted: dict[str, Any] = {}
|
|
174
|
+
for key, nested in value.items():
|
|
175
|
+
key_text = str(key)
|
|
176
|
+
if is_sensitive_key(key_text):
|
|
177
|
+
redacted[key_text] = "[REDACTED]"
|
|
178
|
+
else:
|
|
179
|
+
redacted[key_text] = redact(nested)
|
|
180
|
+
return redacted
|
|
181
|
+
if isinstance(value, list):
|
|
182
|
+
return [redact(item) for item in value]
|
|
183
|
+
return value
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def is_sensitive_key(key: str) -> bool:
|
|
187
|
+
normalized = key.lower().replace("-", "_")
|
|
188
|
+
return any(fragment in normalized for fragment in SENSITIVE_KEY_FRAGMENTS)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def json_size(value: Any) -> int:
|
|
192
|
+
return len(json.dumps(value or {}, ensure_ascii=False, separators=(",", ":"), sort_keys=True).encode("utf-8"))
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def pretty(value: Any) -> str:
|
|
196
|
+
return json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def timestamp() -> str:
|
|
200
|
+
return datetime.now(UTC).isoformat()
|