Skip to content

Commit 6fce6b5

Browse files
Rick Valdesclaude
andcommitted
Let the assistant configure the UTMStack connection in-conversation
Adds two MCP tools, configure_server and remove_server, so a user can set up or change their SIEM connection (URL + API key or username/password) by asking the assistant — "connect to my UTMStack at ... with API key ..." — instead of running utmstack-mcp init separately. The change validates against the live API, writes the same owner-only config file, reloads the registry in-process so it applies to the current session, and never echoes the stored secret. This closes the gap the CLI had: the LLM key was already configurable in the CLI via /connect, but the SIEM connection was not — it required a separate binary. Now both are reachable from the CLI. configure_server deliberately cannot set allowAgentCommands. Enabling a remote SYSTEM shell must stay a deliberate out-of-band action (the config file or the init wizard), never something reachable from a conversation that may carry attacker-influenced log content — the same boundary that keeps run_agent_command opt-in. Also fixes a real latent bug found by type-checking while adding this: an earlier change had inserted the bulk-id guard into add_alert_notes, which takes a singular alert_id, so the function referenced an undefined `alert_ids` and would have raised NameError on any call. The tool had never been exercised. The config writer, reader, and Windows ACL logic move to config.py so the wizard and the new tools share one 0600-from-first-byte code path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4b15e5f commit 6fce6b5

7 files changed

Lines changed: 256 additions & 103 deletions

File tree

‎.github/workflows/release.yml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,8 @@ jobs:
7979
tools = json.loads(p.stdout.readline())["result"]["tools"]
8080
print(f"enumerated {len(tools)} tools")
8181
assert len(tools) >= 40, f"expected >=40 tools, got {len(tools)}"
82-
for required in ("ping", "search_alerts", "run_agent_command", "list_servers"):
82+
for required in ("ping", "search_alerts", "run_agent_command", "list_servers",
83+
"configure_server", "remove_server"):
8384
assert any(t["name"] == required for t in tools), f"missing tool: {required}"
8485
p.stdin.close(); p.terminate()
8586
print("handshake OK")

‎README.md‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,11 +194,24 @@ through `search_logs` and `search_alerts`, which puts a remote shell downstream
194194
do not control. Enable it per server with `"allowAgentCommands": true`, only where you want
195195
that capability.
196196

197+
### Configuring from the assistant
198+
199+
Beyond `utmstack-mcp init`, the assistant can set up or change a connection when you
200+
ask it to in conversation — "connect to my UTMStack at https://utm.example.com with API
201+
key …", or "change the UTMStack URL to …". It uses the `configure_server` tool, which
202+
validates against the live API, writes the same owner-only config file, and applies the
203+
change immediately. `remove_server` deletes a connection.
204+
205+
For safety, `configure_server` cannot enable remote agent command execution — turning on
206+
`allowAgentCommands` stays a deliberate edit to the config file or a run of
207+
`utmstack-mcp init`, never something reachable from a conversation that may contain
208+
attacker-influenced log data.
209+
197210
---
198211

199212
## Tools
200213

201-
**Servers and health** — `list_servers`, `use_server`, `ping`, `whoami`, `get_version`
214+
**Servers and health** — `list_servers`, `use_server`, `configure_server`, `remove_server`, `ping`, `whoami`, `get_version`
202215

203216
**Alerts** — `count_open_alerts`, `search_alerts`, `get_alert`, `get_alerts_for_response`,
204217
`change_alert_status`, `mark_alert_false_positive`, `add_alert_notes`, `add_alert_tags`

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "utmstack-mcp"
7-
version = "1.0.0"
7+
version = "1.0.1"
88
description = "MCP server for the UTMStack SIEM/XDR platform"
99
readme = "README.md"
1010
requires-python = ">=3.10"

‎utmstack_mcp/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""UTMStack MCP server."""
22

3-
__version__ = "1.0.0"
3+
__version__ = "1.0.1"

‎utmstack_mcp/config.py‎

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,10 @@
1919

2020
from __future__ import annotations
2121

22+
import getpass
2223
import json
2324
import os
25+
import subprocess
2426
import sys
2527
from pathlib import Path
2628
from typing import Any, Optional
@@ -403,5 +405,91 @@ def build_registry() -> None:
403405
set_active(DEFAULT_SERVER)
404406
if not SERVERS:
405407
CONFIG_WARNINGS.append(
406-
"no UTMStack servers configured — run `utmstack-mcp init` to set one up"
408+
"no UTMStack servers configured — run `utmstack-mcp init`, or ask the "
409+
"assistant to configure one (configure_server)"
407410
)
411+
412+
413+
# --------------------------------------------------------------------------- #
414+
# Reading and writing the user config file
415+
#
416+
# One writer, shared by the `init` wizard and the configure_server/remove_server
417+
# tools, so the file is always created 0600 (owner-only from the first byte) and
418+
# ACL-restricted on Windows the same way regardless of who wrote it.
419+
# --------------------------------------------------------------------------- #
420+
421+
def read_config_entries() -> list[dict]:
422+
"""The server array from the user config file, or [] if absent/unreadable."""
423+
path = config_file()
424+
if not path.exists():
425+
return []
426+
try:
427+
data = json.loads(path.read_text())
428+
except Exception:
429+
return []
430+
if isinstance(data, dict):
431+
data = data.get("servers", [data]) if "servers" in data else [data]
432+
return [e for e in data if isinstance(e, dict)] if isinstance(data, list) else []
433+
434+
435+
def write_config_entries(entries: list[dict]) -> Path:
436+
"""Write the server array to the user config file, owner-readable only."""
437+
path = config_file()
438+
path.parent.mkdir(parents=True, exist_ok=True)
439+
# Create with 0600 from the outset so the credentials are never briefly
440+
# world-readable between write and chmod.
441+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
442+
with os.fdopen(fd, "w") as fh:
443+
json.dump(entries, fh, indent=2)
444+
fh.write("\n")
445+
if os.name != "nt":
446+
os.chmod(path, 0o600)
447+
else:
448+
restrict_windows_acl(path)
449+
return path
450+
451+
452+
def current_user_sid() -> Optional[str]:
453+
"""The current user's SID, via ``whoami /user``.
454+
455+
Granting by SID rather than by name avoids the ways name lookup fails:
456+
a machine account under SYSTEM (``HOST$``, which icacls cannot map),
457+
domain accounts needing ``DOMAIN\\user``, and renamed accounts.
458+
"""
459+
try:
460+
r = subprocess.run(["whoami", "/user", "/fo", "csv", "/nh"],
461+
capture_output=True, text=True, timeout=15)
462+
if r.returncode != 0:
463+
return None
464+
for part in (p.strip().strip('"') for p in r.stdout.strip().split(",")):
465+
if part.startswith("S-1-"):
466+
return part
467+
except Exception:
468+
return None
469+
return None
470+
471+
472+
def restrict_windows_acl(path: Path) -> bool:
473+
"""Restrict a file to the current user on Windows. Returns True on success."""
474+
sid = current_user_sid()
475+
principal = f"*{sid}" if sid else (os.environ.get("USERNAME") or getpass.getuser())
476+
try:
477+
r = subprocess.run(
478+
["icacls", str(path), "/inheritance:r", "/grant:r", f"{principal}:F"],
479+
capture_output=True, text=True, timeout=30)
480+
except Exception as e:
481+
_warn(f"could not restrict file permissions on {path} ({e})")
482+
return False
483+
if r.returncode != 0:
484+
_warn(f"could not restrict permissions on {path}; other administrators may be "
485+
f"able to read it. Fix with: icacls \"{path}\" /inheritance:r /grant:r "
486+
f"\"%USERNAME%\":F")
487+
return False
488+
try:
489+
chk = subprocess.run(["icacls", str(path)], capture_output=True, text=True, timeout=15)
490+
if "(I)" in chk.stdout:
491+
_warn(f"{path} still has inherited permissions; other administrators may read it.")
492+
return False
493+
except Exception:
494+
pass
495+
return True

‎utmstack_mcp/server.py‎

Lines changed: 145 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,7 @@
2525
from mcp.server.fastmcp import FastMCP
2626

2727
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
3529
from .config import resolve as _resolve
3630
from .config import unknown_server_error as _unknown_server_error
3731

@@ -188,6 +182,150 @@ def use_server(name: str) -> str:
188182
"authMode": s.auth_mode}, indent=2)
189183

190184

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+
191329
@mcp.tool()
192330
def ping(server: str = "") -> str:
193331
"""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
373511
def add_alert_notes(alert_id: str, notes: str, server: str = "") -> str:
374512
"""Add (overwrite) the analyst notes on an alert. Pass an empty string to clear notes.
375513
server: which configured server to target (default active/default)."""
376-
bad = _check_bulk_ids(alert_ids)
377-
if bad:
378-
return bad
379514
return _req("POST", "/api/utm-alerts/notes", params={"alertId": alert_id},
380515
json_body=notes, server=server)
381516

0 commit comments

Comments
 (0)