|
25 | 25 | from mcp.server.fastmcp import FastMCP |
26 | 26 |
|
27 | 27 | from . import config |
28 | | -from .config import ( |
29 | | - SERVERS, |
30 | | - UnknownServer, |
31 | | - _as_bool, |
32 | | - _env, |
33 | | - _warn, |
34 | | -) |
| 28 | +from .config import SERVERS, UnknownServer |
35 | 29 | from .config import resolve as _resolve |
36 | 30 | from .config import unknown_server_error as _unknown_server_error |
37 | 31 |
|
@@ -188,6 +182,150 @@ def use_server(name: str) -> str: |
188 | 182 | "authMode": s.auth_mode}, indent=2) |
189 | 183 |
|
190 | 184 |
|
| 185 | +@mcp.tool() |
| 186 | +def configure_server(url: str = "", api_key: str = "", username: str = "", |
| 187 | + password: str = "", name: str = "default", |
| 188 | + verify_ssl: bool = True, ca_bundle: str = "", |
| 189 | + make_default: bool = True, validate: bool = True) -> str: |
| 190 | + """Add or update a UTMStack server connection (URL + credentials) and save it. |
| 191 | +
|
| 192 | + Use this when the user asks to connect the assistant to their UTMStack, or to |
| 193 | + change the URL or credentials of an existing connection. The connection is |
| 194 | + written to the owner-only config file and takes effect immediately — the user |
| 195 | + does not need to run `utmstack-mcp init` or restart anything. |
| 196 | +
|
| 197 | + Updating an existing server (same `name`) merges: fields you leave blank keep |
| 198 | + their current values, so "change the URL to X" updates only the URL. |
| 199 | +
|
| 200 | + Args: |
| 201 | + url: UTMStack base URL, e.g. https://utm.example.com (required for a |
| 202 | + brand-new server). |
| 203 | + api_key: UTMStack API key (Settings -> API keys). Provide this OR |
| 204 | + username+password. |
| 205 | + username: console login username. Recommended — it is the same login as |
| 206 | + the web UI and the only auth that supports run_agent_command. |
| 207 | + password: console login password. |
| 208 | + name: identifier for this connection (default "default"). |
| 209 | + verify_ssl: verify the server's TLS certificate. Defaults to true; set a |
| 210 | + ca_bundle to trust a self-signed certificate rather than |
| 211 | + turning this off. |
| 212 | + ca_bundle: path to a CA certificate (.pem) that signs the server's cert. |
| 213 | + make_default: make this the default server for later calls. |
| 214 | + validate: test the connection before saving (recommended). If it fails, |
| 215 | + nothing is written. |
| 216 | +
|
| 217 | + Does not enable remote agent command execution — that stays a deliberate |
| 218 | + edit to the config file (or `utmstack-mcp init`), never something set from a |
| 219 | + conversation. Never returns the stored secret. |
| 220 | + """ |
| 221 | + name = (name or "default").strip() |
| 222 | + if not name: |
| 223 | + return json.dumps({"error": True, "reason": "name cannot be empty"}, indent=2) |
| 224 | + |
| 225 | + entries = config.read_config_entries() |
| 226 | + existing = next((e for e in entries if str(e.get("name", "")).lower() == name.lower()), None) |
| 227 | + |
| 228 | + if existing is None and not url: |
| 229 | + return json.dumps({"error": True, |
| 230 | + "reason": f"'{name}' is a new server, so a url is required"}, |
| 231 | + indent=2) |
| 232 | + |
| 233 | + entry: dict[str, Any] = dict(existing) if existing else {"name": name} |
| 234 | + if url: |
| 235 | + u = url.strip() |
| 236 | + if not u.startswith(("http://", "https://")): |
| 237 | + u = "https://" + u |
| 238 | + entry["url"] = u.rstrip("/") |
| 239 | + # Auth: apply whatever was provided. Setting one method does not wipe another |
| 240 | + # already on file, so a partial update stays non-destructive. |
| 241 | + if api_key: |
| 242 | + entry["apiKey"] = api_key |
| 243 | + if username: |
| 244 | + entry["user"] = username |
| 245 | + if password: |
| 246 | + entry["pass"] = password |
| 247 | + if ca_bundle: |
| 248 | + entry["caBundle"] = ca_bundle |
| 249 | + entry["verifySSL"] = bool(verify_ssl) |
| 250 | + # allowAgentCommands is intentionally NOT settable here — see the docstring. |
| 251 | + |
| 252 | + if not (entry.get("user") and entry.get("pass")) and not entry.get("apiKey") \ |
| 253 | + and not entry.get("jwt"): |
| 254 | + return json.dumps({"error": True, |
| 255 | + "reason": "no credentials — provide api_key, or username and password"}, |
| 256 | + indent=2) |
| 257 | + |
| 258 | + # Validate against the live API before persisting anything. |
| 259 | + if validate: |
| 260 | + verify_arg: Any = entry.get("caBundle") or bool(entry.get("verifySSL", True)) |
| 261 | + base = entry["url"] |
| 262 | + try: |
| 263 | + with httpx.Client(base_url=base, verify=verify_arg, timeout=20.0) as c: |
| 264 | + if entry.get("user") and entry.get("pass"): |
| 265 | + r = c.post("/api/authenticate", |
| 266 | + json={"username": entry["user"], "password": entry["pass"], |
| 267 | + "rememberMe": True}) |
| 268 | + ok = r.status_code < 400 and bool( |
| 269 | + (r.json() or {}).get("token") or (r.json() or {}).get("id_token")) |
| 270 | + detail = "credentials accepted" if ok else f"login rejected (HTTP {r.status_code})" |
| 271 | + else: |
| 272 | + r = c.get("/api/ping", headers={"Utm-Api-Key": entry["apiKey"]}) |
| 273 | + ok = r.status_code < 400 |
| 274 | + detail = "API key accepted" if ok else f"API key rejected (HTTP {r.status_code})" |
| 275 | + except Exception as e: |
| 276 | + return json.dumps({"error": True, "reason": "could not reach the server", |
| 277 | + "detail": f"{type(e).__name__}: {e}", |
| 278 | + "hint": "check the URL; for a self-signed cert set ca_bundle, " |
| 279 | + "or pass verify_ssl=false to test"}, indent=2) |
| 280 | + if not ok: |
| 281 | + return json.dumps({"error": True, "reason": detail, |
| 282 | + "hint": "nothing was saved"}, indent=2) |
| 283 | + |
| 284 | + if make_default: |
| 285 | + for e in entries: |
| 286 | + e.pop("default", None) |
| 287 | + entry["default"] = True |
| 288 | + |
| 289 | + entries = [e for e in entries if str(e.get("name", "")).lower() != name.lower()] |
| 290 | + entries.append(entry) |
| 291 | + path = config.write_config_entries(entries) |
| 292 | + config.build_registry() # reload so the change applies to this session immediately |
| 293 | + |
| 294 | + return json.dumps({ |
| 295 | + "ok": True, |
| 296 | + "action": "updated" if existing else "added", |
| 297 | + "server": name, |
| 298 | + "url": entry["url"], |
| 299 | + "authMode": config.resolve(name).auth_mode, |
| 300 | + "isDefault": bool(entry.get("default")), |
| 301 | + "savedTo": str(path), |
| 302 | + "note": "connection is live now; secrets were stored but are not shown here", |
| 303 | + }, indent=2) |
| 304 | + |
| 305 | + |
| 306 | +@mcp.tool() |
| 307 | +def remove_server(name: str) -> str: |
| 308 | + """Remove a configured UTMStack server connection by name and save. |
| 309 | +
|
| 310 | + Deletes the connection (including its stored credentials) from the config |
| 311 | + file and applies the change to this session immediately.""" |
| 312 | + name = (name or "").strip() |
| 313 | + if not name: |
| 314 | + return json.dumps({"error": True, "reason": "name is required"}, indent=2) |
| 315 | + entries = config.read_config_entries() |
| 316 | + remaining = [e for e in entries if str(e.get("name", "")).lower() != name.lower()] |
| 317 | + if len(remaining) == len(entries): |
| 318 | + return _unknown_server_error(name) |
| 319 | + # If we removed the default, promote the first remaining server. |
| 320 | + if not any(e.get("default") for e in remaining) and remaining: |
| 321 | + remaining[0]["default"] = True |
| 322 | + path = config.write_config_entries(remaining) |
| 323 | + config.build_registry() |
| 324 | + return json.dumps({"ok": True, "removed": name, |
| 325 | + "remaining": [e.get("name") for e in remaining], |
| 326 | + "savedTo": str(path)}, indent=2) |
| 327 | + |
| 328 | + |
191 | 329 | @mcp.tool() |
192 | 330 | def ping(server: str = "") -> str: |
193 | 331 | """Health-check the UTMStack server and confirm auth is valid. |
@@ -373,9 +511,6 @@ def mark_alert_false_positive(alert_ids: list[str], observation: str = "Confirme |
373 | 511 | def add_alert_notes(alert_id: str, notes: str, server: str = "") -> str: |
374 | 512 | """Add (overwrite) the analyst notes on an alert. Pass an empty string to clear notes. |
375 | 513 | server: which configured server to target (default active/default).""" |
376 | | - bad = _check_bulk_ids(alert_ids) |
377 | | - if bad: |
378 | | - return bad |
379 | 514 | return _req("POST", "/api/utm-alerts/notes", params={"alertId": alert_id}, |
380 | 515 | json_body=notes, server=server) |
381 | 516 |
|
|
0 commit comments