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.
@@ -0,0 +1 @@
1
+ """dope.security Python packages."""
@@ -0,0 +1,5 @@
1
+ """dope.security MCP server."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.1.0"
@@ -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
+ )