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.
@@ -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()