dopesecurity-mcp-server 0.1.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.
- dopesecurity/__init__.py +1 -0
- dopesecurity/mcp_server/__init__.py +5 -0
- dopesecurity/mcp_server/__main__.py +135 -0
- dopesecurity/mcp_server/auth.py +209 -0
- dopesecurity/mcp_server/config.py +172 -0
- dopesecurity/mcp_server/errors.py +236 -0
- dopesecurity/mcp_server/flightdeck/__init__.py +1 -0
- dopesecurity/mcp_server/flightdeck/client.py +437 -0
- dopesecurity/mcp_server/flightdeck/models.py +27 -0
- dopesecurity/mcp_server/instructions.py +69 -0
- dopesecurity/mcp_server/py.typed +1 -0
- dopesecurity/mcp_server/schemas.py +578 -0
- dopesecurity/mcp_server/server.py +139 -0
- dopesecurity/mcp_server/services/__init__.py +1 -0
- dopesecurity/mcp_server/services/custom_categories.py +93 -0
- dopesecurity/mcp_server/services/endpoints.py +43 -0
- dopesecurity/mcp_server/services/policies.py +433 -0
- dopesecurity/mcp_server/tools/__init__.py +17 -0
- dopesecurity/mcp_server/tools/custom_categories.py +158 -0
- dopesecurity/mcp_server/tools/endpoints.py +69 -0
- dopesecurity/mcp_server/tools/policies.py +438 -0
- dopesecurity_mcp_server-0.1.0.dist-info/METADATA +241 -0
- dopesecurity_mcp_server-0.1.0.dist-info/RECORD +26 -0
- dopesecurity_mcp_server-0.1.0.dist-info/WHEEL +4 -0
- dopesecurity_mcp_server-0.1.0.dist-info/entry_points.txt +2 -0
- dopesecurity_mcp_server-0.1.0.dist-info/licenses/LICENSE +21 -0
dopesecurity/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""dope.security Python packages."""
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Console entry point for the dope.security MCP server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
|
|
8
|
+
import structlog
|
|
9
|
+
|
|
10
|
+
from dopesecurity.mcp_server.config import (
|
|
11
|
+
CliConfigOverrides,
|
|
12
|
+
configure_logging,
|
|
13
|
+
load_settings,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
LOG_LEVEL_CHOICES = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
20
|
+
parser = argparse.ArgumentParser(
|
|
21
|
+
prog="dopesecurity-mcp-server",
|
|
22
|
+
description=(
|
|
23
|
+
"Local MCP server for the dope.security Flightdeck partner API. "
|
|
24
|
+
"Reads credentials from DOPE_CLIENT_ID and DOPE_CLIENT_SECRET."
|
|
25
|
+
),
|
|
26
|
+
)
|
|
27
|
+
parser.add_argument(
|
|
28
|
+
"--enable-mutations",
|
|
29
|
+
action="store_true",
|
|
30
|
+
default=None,
|
|
31
|
+
help="Expose write tools that modify tenant state.",
|
|
32
|
+
)
|
|
33
|
+
parser.add_argument(
|
|
34
|
+
"--enable-destructive",
|
|
35
|
+
action="store_true",
|
|
36
|
+
default=None,
|
|
37
|
+
help=(
|
|
38
|
+
"Additionally expose destructive tools that drop a whole policy or "
|
|
39
|
+
"custom category, or reset whole sections to base. Requires "
|
|
40
|
+
"--enable-mutations."
|
|
41
|
+
),
|
|
42
|
+
)
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"--timeout-seconds",
|
|
45
|
+
type=float,
|
|
46
|
+
default=None,
|
|
47
|
+
metavar="FLOAT",
|
|
48
|
+
help="HTTP timeout for Flightdeck calls in seconds.",
|
|
49
|
+
)
|
|
50
|
+
parser.add_argument(
|
|
51
|
+
"--log-level",
|
|
52
|
+
choices=LOG_LEVEL_CHOICES,
|
|
53
|
+
default=None,
|
|
54
|
+
help="Logging verbosity. Logs are written to stderr.",
|
|
55
|
+
)
|
|
56
|
+
parser.add_argument(
|
|
57
|
+
"--log-file",
|
|
58
|
+
type=str,
|
|
59
|
+
default=None,
|
|
60
|
+
metavar="PATH",
|
|
61
|
+
help=(
|
|
62
|
+
"If set, also append JSON logs to this file (handy when running "
|
|
63
|
+
"under MCP Inspector, which hides server stderr)."
|
|
64
|
+
),
|
|
65
|
+
)
|
|
66
|
+
parser.add_argument(
|
|
67
|
+
"--token-refresh-skew-seconds",
|
|
68
|
+
type=float,
|
|
69
|
+
default=None,
|
|
70
|
+
metavar="FLOAT",
|
|
71
|
+
help=argparse.SUPPRESS,
|
|
72
|
+
)
|
|
73
|
+
parser.add_argument(
|
|
74
|
+
"--base-url",
|
|
75
|
+
type=str,
|
|
76
|
+
default=None,
|
|
77
|
+
metavar="URL",
|
|
78
|
+
help=argparse.SUPPRESS,
|
|
79
|
+
)
|
|
80
|
+
return parser
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _overrides_from_args(args: argparse.Namespace) -> CliConfigOverrides:
|
|
84
|
+
return CliConfigOverrides(
|
|
85
|
+
enable_mutations=args.enable_mutations,
|
|
86
|
+
enable_destructive=args.enable_destructive,
|
|
87
|
+
timeout_seconds=args.timeout_seconds,
|
|
88
|
+
log_level=args.log_level,
|
|
89
|
+
token_refresh_skew_seconds=args.token_refresh_skew_seconds,
|
|
90
|
+
base_url=args.base_url,
|
|
91
|
+
log_file=args.log_file,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
96
|
+
parser = _build_parser()
|
|
97
|
+
args = parser.parse_args(argv)
|
|
98
|
+
overrides = _overrides_from_args(args)
|
|
99
|
+
settings = load_settings(overrides)
|
|
100
|
+
configure_logging(settings.log_level, log_file=settings.log_file)
|
|
101
|
+
|
|
102
|
+
log = structlog.get_logger("dopesecurity.mcp_server.main")
|
|
103
|
+
# WARNING: client_id and base url logged in clear text on purpose for
|
|
104
|
+
# debugging credential issues; tighten before production.
|
|
105
|
+
log.info(
|
|
106
|
+
"main.startup",
|
|
107
|
+
log_level=settings.log_level,
|
|
108
|
+
api_base_url=settings.api_base_url,
|
|
109
|
+
enable_mutations=settings.enable_mutations,
|
|
110
|
+
enable_destructive=settings.enable_destructive,
|
|
111
|
+
timeout_seconds=settings.timeout_seconds,
|
|
112
|
+
token_refresh_skew_seconds=settings.token_refresh_skew_seconds,
|
|
113
|
+
client_id=settings.client_id,
|
|
114
|
+
client_secret_set=bool(settings.client_secret.get_secret_value()),
|
|
115
|
+
client_secret_length=len(settings.client_secret.get_secret_value()),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# Imported lazily so --help does not require constructing FastMCP.
|
|
119
|
+
from dopesecurity.mcp_server.server import create_server
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
mcp = create_server(settings)
|
|
123
|
+
mcp.run()
|
|
124
|
+
except Exception as exc:
|
|
125
|
+
log.exception(
|
|
126
|
+
"main.fatal",
|
|
127
|
+
exc_type=type(exc).__name__,
|
|
128
|
+
exc_message=str(exc),
|
|
129
|
+
)
|
|
130
|
+
raise
|
|
131
|
+
return 0
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
if __name__ == "__main__": # pragma: no cover - exercised via console script
|
|
135
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Flightdeck OAuth client-credentials token manager."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
import structlog
|
|
12
|
+
|
|
13
|
+
from dopesecurity.mcp_server.config import Settings
|
|
14
|
+
from dopesecurity.mcp_server.errors import (
|
|
15
|
+
FlightdeckAuthenticationError,
|
|
16
|
+
FlightdeckServerError,
|
|
17
|
+
FlightdeckTransportError,
|
|
18
|
+
)
|
|
19
|
+
from dopesecurity.mcp_server.flightdeck.models import TokenResponse # noqa: TID252
|
|
20
|
+
|
|
21
|
+
TOKEN_PATH = "/partner/oauth/token"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class CachedToken:
|
|
26
|
+
access_token: str
|
|
27
|
+
expires_at_monotonic: float
|
|
28
|
+
|
|
29
|
+
def is_expired(self, *, now: float) -> bool:
|
|
30
|
+
return now >= self.expires_at_monotonic
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class FlightdeckTokenManager:
|
|
34
|
+
"""Caches a Flightdeck bearer token and refreshes it before expiry."""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
http: httpx.AsyncClient,
|
|
39
|
+
settings: Settings,
|
|
40
|
+
logger: structlog.stdlib.BoundLogger | None = None,
|
|
41
|
+
) -> None:
|
|
42
|
+
self._http = http
|
|
43
|
+
self._settings = settings
|
|
44
|
+
self._logger = logger or structlog.get_logger("dopesecurity.mcp_server.auth")
|
|
45
|
+
self._lock = asyncio.Lock()
|
|
46
|
+
self._cached: CachedToken | None = None
|
|
47
|
+
|
|
48
|
+
def invalidate(self) -> None:
|
|
49
|
+
"""Discard the cached token so the next call exchanges credentials again."""
|
|
50
|
+
|
|
51
|
+
self._logger.info("auth.token.invalidate", had_cached=self._cached is not None)
|
|
52
|
+
self._cached = None
|
|
53
|
+
|
|
54
|
+
async def get_token(self, *, force_refresh: bool = False) -> str:
|
|
55
|
+
if not force_refresh:
|
|
56
|
+
current = self._cached
|
|
57
|
+
if current is not None and not current.is_expired(now=time.monotonic()):
|
|
58
|
+
self._logger.debug(
|
|
59
|
+
"auth.token.cache_hit",
|
|
60
|
+
expires_in=max(current.expires_at_monotonic - time.monotonic(), 0.0),
|
|
61
|
+
)
|
|
62
|
+
return current.access_token
|
|
63
|
+
|
|
64
|
+
async with self._lock:
|
|
65
|
+
if not force_refresh:
|
|
66
|
+
current = self._cached
|
|
67
|
+
if current is not None and not current.is_expired(now=time.monotonic()):
|
|
68
|
+
self._logger.debug("auth.token.cache_hit_after_lock")
|
|
69
|
+
return current.access_token
|
|
70
|
+
self._logger.info(
|
|
71
|
+
"auth.token.exchange_start",
|
|
72
|
+
force_refresh=force_refresh,
|
|
73
|
+
base_url=str(self._http.base_url),
|
|
74
|
+
token_path=TOKEN_PATH,
|
|
75
|
+
client_id=self._settings.client_id,
|
|
76
|
+
)
|
|
77
|
+
token = await self._exchange()
|
|
78
|
+
self._cached = token
|
|
79
|
+
self._logger.info(
|
|
80
|
+
"auth.token.exchange_success",
|
|
81
|
+
expires_at_monotonic=token.expires_at_monotonic,
|
|
82
|
+
seconds_until_expiry=max(
|
|
83
|
+
token.expires_at_monotonic - time.monotonic(), 0.0
|
|
84
|
+
),
|
|
85
|
+
)
|
|
86
|
+
return token.access_token
|
|
87
|
+
|
|
88
|
+
async def _exchange(self) -> CachedToken:
|
|
89
|
+
body = {
|
|
90
|
+
"grant_type": "client_credentials",
|
|
91
|
+
"client_id": self._settings.client_id,
|
|
92
|
+
"client_secret": self._settings.client_secret.get_secret_value(),
|
|
93
|
+
}
|
|
94
|
+
# WARNING: logging the full body (including the client_secret) is
|
|
95
|
+
# intentional for credential debugging; remove before production.
|
|
96
|
+
self._logger.debug(
|
|
97
|
+
"auth.token.request",
|
|
98
|
+
url=str(httpx.URL(self._http.base_url).join(TOKEN_PATH)),
|
|
99
|
+
body=body,
|
|
100
|
+
)
|
|
101
|
+
try:
|
|
102
|
+
response = await self._http.post(TOKEN_PATH, json=body)
|
|
103
|
+
except httpx.TransportError as exc:
|
|
104
|
+
self._logger.exception(
|
|
105
|
+
"auth.token.transport_error",
|
|
106
|
+
exc_type=type(exc).__name__,
|
|
107
|
+
exc_message=str(exc),
|
|
108
|
+
request_url=str(getattr(exc, "request", None) and exc.request.url),
|
|
109
|
+
)
|
|
110
|
+
raise FlightdeckTransportError(
|
|
111
|
+
f"Failed to reach Flightdeck token endpoint: {type(exc).__name__}: {exc}"
|
|
112
|
+
) from exc
|
|
113
|
+
except Exception as exc: # noqa: BLE001 - log everything else too
|
|
114
|
+
self._logger.exception(
|
|
115
|
+
"auth.token.unexpected_request_error",
|
|
116
|
+
exc_type=type(exc).__name__,
|
|
117
|
+
exc_message=str(exc),
|
|
118
|
+
)
|
|
119
|
+
raise
|
|
120
|
+
|
|
121
|
+
# WARNING: response body and headers may contain tokens or hints
|
|
122
|
+
# about credential failures; logged on purpose for debugging.
|
|
123
|
+
self._logger.info(
|
|
124
|
+
"auth.token.response",
|
|
125
|
+
status=response.status_code,
|
|
126
|
+
headers=dict(response.headers),
|
|
127
|
+
body_text=response.text,
|
|
128
|
+
body_json=_safe_json(response),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
if response.status_code in (400, 401, 403):
|
|
132
|
+
details = _safe_json(response)
|
|
133
|
+
self._logger.error(
|
|
134
|
+
"auth.token.rejected",
|
|
135
|
+
status=response.status_code,
|
|
136
|
+
details=details,
|
|
137
|
+
body_text=response.text,
|
|
138
|
+
)
|
|
139
|
+
raise FlightdeckAuthenticationError(
|
|
140
|
+
f"Flightdeck rejected the client credentials (status {response.status_code}): "
|
|
141
|
+
f"{response.text}",
|
|
142
|
+
details=details,
|
|
143
|
+
)
|
|
144
|
+
if response.status_code >= 500:
|
|
145
|
+
details = _safe_json(response)
|
|
146
|
+
self._logger.error(
|
|
147
|
+
"auth.token.server_error",
|
|
148
|
+
status=response.status_code,
|
|
149
|
+
details=details,
|
|
150
|
+
body_text=response.text,
|
|
151
|
+
)
|
|
152
|
+
raise FlightdeckServerError(
|
|
153
|
+
f"Flightdeck token endpoint returned a server error (status "
|
|
154
|
+
f"{response.status_code}): {response.text}",
|
|
155
|
+
details=details,
|
|
156
|
+
)
|
|
157
|
+
if response.status_code != 200:
|
|
158
|
+
details = _safe_json(response)
|
|
159
|
+
self._logger.error(
|
|
160
|
+
"auth.token.unexpected_status",
|
|
161
|
+
status=response.status_code,
|
|
162
|
+
details=details,
|
|
163
|
+
body_text=response.text,
|
|
164
|
+
)
|
|
165
|
+
raise FlightdeckAuthenticationError(
|
|
166
|
+
f"Unexpected token response status {response.status_code}: {response.text}",
|
|
167
|
+
details=details,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
parsed = TokenResponse.model_validate(response.json())
|
|
172
|
+
except Exception as exc: # noqa: BLE001 - normalize parse failures
|
|
173
|
+
self._logger.exception(
|
|
174
|
+
"auth.token.parse_failed",
|
|
175
|
+
exc_type=type(exc).__name__,
|
|
176
|
+
exc_message=str(exc),
|
|
177
|
+
body_text=response.text,
|
|
178
|
+
)
|
|
179
|
+
raise FlightdeckAuthenticationError(
|
|
180
|
+
f"Token response could not be parsed: {type(exc).__name__}: {exc}",
|
|
181
|
+
) from exc
|
|
182
|
+
|
|
183
|
+
if parsed.token_type.lower() != "bearer":
|
|
184
|
+
self._logger.error(
|
|
185
|
+
"auth.token.unsupported_token_type",
|
|
186
|
+
token_type=parsed.token_type,
|
|
187
|
+
)
|
|
188
|
+
raise FlightdeckAuthenticationError(
|
|
189
|
+
f"Unsupported token_type {parsed.token_type!r}",
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
skew = self._settings.token_refresh_skew_seconds
|
|
193
|
+
expires_at = time.monotonic() + max(parsed.expires_in - skew, 0.0)
|
|
194
|
+
# WARNING: full access token logged for debugging.
|
|
195
|
+
self._logger.debug(
|
|
196
|
+
"auth.token.parsed",
|
|
197
|
+
token_type=parsed.token_type,
|
|
198
|
+
expires_in=parsed.expires_in,
|
|
199
|
+
skew=skew,
|
|
200
|
+
access_token=parsed.access_token,
|
|
201
|
+
)
|
|
202
|
+
return CachedToken(access_token=parsed.access_token, expires_at_monotonic=expires_at)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _safe_json(response: httpx.Response) -> Any:
|
|
206
|
+
try:
|
|
207
|
+
return response.json()
|
|
208
|
+
except ValueError:
|
|
209
|
+
return None
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Runtime configuration and logging setup."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any, Literal
|
|
9
|
+
|
|
10
|
+
import structlog
|
|
11
|
+
from pydantic import Field, SecretStr, field_validator, model_validator
|
|
12
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
13
|
+
|
|
14
|
+
LogLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
|
15
|
+
|
|
16
|
+
DEFAULT_BASE_URL = "https://api.flightdeck.dope.security/v1"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class CliConfigOverrides:
|
|
21
|
+
"""CLI overrides for non-secret settings."""
|
|
22
|
+
|
|
23
|
+
enable_mutations: bool | None = None
|
|
24
|
+
enable_destructive: bool | None = None
|
|
25
|
+
timeout_seconds: float | None = None
|
|
26
|
+
log_level: str | None = None
|
|
27
|
+
token_refresh_skew_seconds: float | None = None
|
|
28
|
+
base_url: str | None = None
|
|
29
|
+
log_file: str | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Settings(BaseSettings):
|
|
33
|
+
"""Process-wide settings sourced from the environment with optional overrides."""
|
|
34
|
+
|
|
35
|
+
model_config = SettingsConfigDict(
|
|
36
|
+
env_prefix="DOPE_",
|
|
37
|
+
env_ignore_empty=True,
|
|
38
|
+
extra="forbid",
|
|
39
|
+
case_sensitive=False,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
client_id: str = Field(validation_alias="DOPE_CLIENT_ID")
|
|
43
|
+
client_secret: SecretStr = Field(validation_alias="DOPE_CLIENT_SECRET")
|
|
44
|
+
enable_mutations: bool = Field(default=False, validation_alias="DOPE_ENABLE_MUTATIONS")
|
|
45
|
+
enable_destructive: bool = Field(
|
|
46
|
+
default=False, validation_alias="DOPE_ENABLE_DESTRUCTIVE"
|
|
47
|
+
)
|
|
48
|
+
timeout_seconds: float = Field(default=30.0, validation_alias="DOPE_TIMEOUT_SECONDS", gt=0)
|
|
49
|
+
log_level: LogLevel = Field(default="INFO", validation_alias="DOPE_LOG_LEVEL")
|
|
50
|
+
token_refresh_skew_seconds: float = Field(
|
|
51
|
+
default=60.0, validation_alias="DOPE_TOKEN_REFRESH_SKEW_SECONDS", ge=0
|
|
52
|
+
)
|
|
53
|
+
api_base_url: str = Field(default=DEFAULT_BASE_URL, validation_alias="DOPE_BASE_URL")
|
|
54
|
+
log_file: str | None = Field(default=None, validation_alias="DOPE_LOG_FILE")
|
|
55
|
+
|
|
56
|
+
@field_validator("log_level", mode="before")
|
|
57
|
+
@classmethod
|
|
58
|
+
def _normalize_log_level(cls, value: Any) -> Any:
|
|
59
|
+
if isinstance(value, str):
|
|
60
|
+
return value.upper()
|
|
61
|
+
return value
|
|
62
|
+
|
|
63
|
+
@model_validator(mode="after")
|
|
64
|
+
def _destructive_requires_mutations(self) -> Settings:
|
|
65
|
+
if self.enable_destructive and not self.enable_mutations:
|
|
66
|
+
raise ValueError(
|
|
67
|
+
"DOPE_ENABLE_DESTRUCTIVE requires DOPE_ENABLE_MUTATIONS to also "
|
|
68
|
+
"be enabled (destructive tools are a subset of write tools)."
|
|
69
|
+
)
|
|
70
|
+
return self
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def load_settings(overrides: CliConfigOverrides | None = None) -> Settings:
|
|
74
|
+
"""Load settings from environment, then apply non-secret CLI overrides."""
|
|
75
|
+
|
|
76
|
+
settings = Settings() # type: ignore[call-arg]
|
|
77
|
+
if overrides is None:
|
|
78
|
+
return settings
|
|
79
|
+
|
|
80
|
+
update: dict[str, Any] = {}
|
|
81
|
+
if overrides.enable_mutations is not None:
|
|
82
|
+
update["enable_mutations"] = overrides.enable_mutations
|
|
83
|
+
if overrides.enable_destructive is not None:
|
|
84
|
+
update["enable_destructive"] = overrides.enable_destructive
|
|
85
|
+
if overrides.timeout_seconds is not None:
|
|
86
|
+
update["timeout_seconds"] = overrides.timeout_seconds
|
|
87
|
+
if overrides.log_level is not None:
|
|
88
|
+
update["log_level"] = overrides.log_level.upper()
|
|
89
|
+
if overrides.token_refresh_skew_seconds is not None:
|
|
90
|
+
update["token_refresh_skew_seconds"] = overrides.token_refresh_skew_seconds
|
|
91
|
+
if overrides.base_url is not None:
|
|
92
|
+
update["api_base_url"] = overrides.base_url
|
|
93
|
+
if overrides.log_file is not None:
|
|
94
|
+
update["log_file"] = overrides.log_file
|
|
95
|
+
|
|
96
|
+
if not update:
|
|
97
|
+
return settings
|
|
98
|
+
merged = settings.model_copy(update=update)
|
|
99
|
+
# model_copy bypasses validators; re-check the destructive invariant
|
|
100
|
+
# so CLI flags like `--enable-destructive` (without --enable-mutations)
|
|
101
|
+
# fail loudly at startup instead of silently registering nothing.
|
|
102
|
+
if merged.enable_destructive and not merged.enable_mutations:
|
|
103
|
+
raise ValueError(
|
|
104
|
+
"--enable-destructive requires --enable-mutations to also be "
|
|
105
|
+
"enabled (destructive tools are a subset of write tools)."
|
|
106
|
+
)
|
|
107
|
+
return merged
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class _TeeWriter:
|
|
111
|
+
"""File-like sink that writes/flushes to multiple underlying streams."""
|
|
112
|
+
|
|
113
|
+
def __init__(self, *streams: Any) -> None:
|
|
114
|
+
self._streams = streams
|
|
115
|
+
|
|
116
|
+
def write(self, data: str) -> int:
|
|
117
|
+
last = 0
|
|
118
|
+
for stream in self._streams:
|
|
119
|
+
try:
|
|
120
|
+
last = stream.write(data)
|
|
121
|
+
except Exception: # noqa: BLE001 - never let logging break the app
|
|
122
|
+
pass
|
|
123
|
+
return last
|
|
124
|
+
|
|
125
|
+
def flush(self) -> None:
|
|
126
|
+
for stream in self._streams:
|
|
127
|
+
try:
|
|
128
|
+
stream.flush()
|
|
129
|
+
except Exception: # noqa: BLE001
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def configure_logging(log_level: str, log_file: str | None = None) -> None:
|
|
134
|
+
"""Configure stdlib logging and structlog to write to stderr (and optional file)."""
|
|
135
|
+
|
|
136
|
+
level_name = log_level.upper()
|
|
137
|
+
level = logging.getLevelNamesMapping().get(level_name, logging.INFO)
|
|
138
|
+
|
|
139
|
+
handler = logging.StreamHandler(stream=sys.stderr)
|
|
140
|
+
handler.setLevel(level)
|
|
141
|
+
handler.setFormatter(logging.Formatter("%(message)s"))
|
|
142
|
+
|
|
143
|
+
root = logging.getLogger()
|
|
144
|
+
for existing in list(root.handlers):
|
|
145
|
+
root.removeHandler(existing)
|
|
146
|
+
root.addHandler(handler)
|
|
147
|
+
root.setLevel(level)
|
|
148
|
+
|
|
149
|
+
sink: Any = sys.stderr
|
|
150
|
+
if log_file:
|
|
151
|
+
# Line-buffered so `tail -f` shows entries as they happen.
|
|
152
|
+
file_stream = open(log_file, "a", buffering=1, encoding="utf-8") # noqa: SIM115
|
|
153
|
+
sink = _TeeWriter(sys.stderr, file_stream)
|
|
154
|
+
# Surface the path so users know where to tail.
|
|
155
|
+
print(f"dopesecurity-mcp-server: logging to {log_file}", file=sys.stderr)
|
|
156
|
+
|
|
157
|
+
structlog.configure(
|
|
158
|
+
processors=[
|
|
159
|
+
structlog.contextvars.merge_contextvars,
|
|
160
|
+
structlog.processors.add_log_level,
|
|
161
|
+
structlog.processors.TimeStamper(fmt="iso"),
|
|
162
|
+
# NOTE: `dict_tracebacks` includes full traceback frames, locals, and
|
|
163
|
+
# the exception chain. This is intentionally verbose for debugging
|
|
164
|
+
# auth/credential issues and may leak sensitive data; tighten before
|
|
165
|
+
# production hardening.
|
|
166
|
+
structlog.processors.dict_tracebacks,
|
|
167
|
+
structlog.processors.JSONRenderer(),
|
|
168
|
+
],
|
|
169
|
+
wrapper_class=structlog.make_filtering_bound_logger(level),
|
|
170
|
+
logger_factory=structlog.PrintLoggerFactory(file=sink),
|
|
171
|
+
cache_logger_on_first_use=True,
|
|
172
|
+
)
|