feat(mcp): add get_log and get_state tools, redact credentials from get_apps - #4775
Conversation
Adds a get_log MCP tool serving predbat.log with level, search, age and line-count filters, and makes get_apps redact credential-like values by default so apps.yaml can be handed to an AI assistant for review without leaking API keys. mask_secret_args now also matches "secret" and "token" key names, which left sigenergy_app_secret, solis_api_secret, solis_access_token, gateway_mqtt_token and mcp_secret in the clear. The log level-filter rules are shared with the web log view so the two cannot drift apart. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rather than serving a ~5MB debug yaml, get_state returns Predbat's state a variable at a time. Called with no arguments it returns every variable small enough to be worth reading (~370 of them, well under 1k tokens on a fresh instance) and describes the large per-minute series - load_minutes, rate_import, pv_today - in an "omitted" section giving type, length and value range, so the caller can ask for one by name instead of guessing. The debug yaml's exclusion list moves to utils.is_debug_excluded_key and is shared with create_debug_yaml, so get_state can never return anything a debug dump would not, and now also drops secret/token key names. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
There are a few concrete API/docs correctness and robustness issues (ordering description mismatch, missing argument validation, and over-broad secret-key exclusion of non-secret expiry metadata) that should be fixed before merging.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds MCP tooling to help AI-assisted debugging/config review by exposing Predbat logs and a size-guarded view of internal state, while reducing the chance of credential leakage via MCP.
Changes:
- Add MCP tools
get_log(bounded, filterablepredbat.log) andget_state(budgeted internal state with omitted summaries). - Redact credential-like values from
get_appsby default and share the debug-yaml exclusion logic viautils.is_debug_excluded_key. - Refactor the web log endpoint to reuse shared log classification/filter helpers; add docs and a new
test_web_mcpsuite.
File summaries
| File | Description |
|---|---|
| docs/components.md | Documents MCP tools, including new get_log/get_state and redaction behavior. |
| apps/predbat/web.py | Reuses shared log reader + log-level classifier/include rules for /api/log. |
| apps/predbat/web_mcp.py | Implements MCP get_log/get_state, adds default-redacted get_apps, and registers new tools. |
| apps/predbat/utils.py | Adds shared secret-key detection, debug exclusion helper, log reader, and log line classification/timestamp parsing. |
| apps/predbat/userinterface.py | Switches debug-yaml generation to shared is_debug_excluded_key. |
| apps/predbat/unit_test.py | Registers the new web_mcp tests. |
| apps/predbat/tests/test_web_mcp.py | Adds coverage for masking, debug exclusion, log rotation/filtering, MCP tools, and /api/log regression behavior. |
Review details
Suppressed comments (2)
apps/predbat/web_mcp.py:1293
get_logcastsmax_linesandhoursdirectly to int/float; invalid client values currently surface only as a generic exception string. Returning explicit argument errors for badmax_lines/hoursmakes the tool easier to consume and avoids exposing Python exception details.
search_term = str(arguments.get("search", "") or "").lower().strip()
max_lines = int(arguments.get("max_lines", MCP_LOG_DEFAULT_LINES))
max_lines = max(1, min(max_lines, MCP_LOG_MAX_LINES))
hours = arguments.get("hours", None)
hours = float(hours) if hours is not None else None
apps/predbat/utils.py:172
read_predbat_log()reads log files with the platform default encoding. If the log contains any non-UTF-8 bytes (or mixed encodings), this can raiseUnicodeDecodeErrorand break both/api/logandget_log. Using an explicit UTF-8 decode witherrors='replace'is more robust for log ingestion.
def read_predbat_log(logfile=PREDBAT_LOG_FILE, logfile_prev=PREDBAT_LOG_FILE_PREV):
"""
Return the contents of predbat.log, prefixed with the rotated previous log when one exists.
"""
logdata = ""
if os.path.exists(logfile):
with open(logfile, "r") as f:
logdata = f.read()
if os.path.exists(logfile_prev):
with open(logfile_prev, "r") as f:
logdata = f.read() + "\n" + logdata
return logdata
- Files reviewed: 7/7 changed files
- Comments generated: 4
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| | `filter` | `all`, `info`, `warnings` (the default) or `errors` | | ||
| | `search` | Only return lines containing this text, case-insensitive | | ||
| | `hours` | Only return lines written in the last N hours | | ||
| | `max_lines` | How many lines to return, most recent first (default 500, maximum 5000) | | ||
|
|
| "properties": { | ||
| "filter": {"type": "string", "description": "Log level to return: all, info, warnings or errors (default warnings)", "enum": list(LOG_FILTER_TYPES)}, | ||
| "search": {"type": "string", "description": "Only return lines containing this text, case-insensitive (optional)"}, | ||
| "hours": {"type": "number", "description": "Only return lines written in the last N hours (optional)"}, | ||
| "max_lines": {"type": "integer", "description": "Maximum number of lines to return, most recent first (default {}, maximum {})".format(MCP_LOG_DEFAULT_LINES, MCP_LOG_MAX_LINES)}, | ||
| }, |
| key_filter = arguments.get("filter", None) | ||
| max_bytes = int(arguments.get("max_bytes", MCP_STATE_DEFAULT_MAX_BYTES)) | ||
| max_bytes = max(1, min(max_bytes, MCP_STATE_MAX_BYTES_LIMIT)) | ||
|
|
||
| state = {} | ||
| omitted = {} | ||
| total_bytes = 0 | ||
| budget_exhausted = False | ||
| unknown_keys = [] | ||
|
|
||
| # Snapshot the key list up front - the plan thread can add attributes while we walk it | ||
| available = list(self.base.__dict__.keys()) | ||
| if requested is not None: | ||
| unknown_keys = [key for key in requested if key not in available] | ||
| candidates = [key for key in requested if key in available] | ||
| else: | ||
| candidates = available | ||
|
|
||
| for key in candidates: | ||
| # Same filter the debug yaml uses, so this can never return what a debug dump won't | ||
| if is_debug_excluded_key(key): | ||
| continue | ||
| if key_filter and not re.search(key_filter, key): | ||
| continue |
| SECRET_KEY_SUBSTRINGS = ("_key", "password", "secret", "token") | ||
|
|
||
| # Key suffixes that match a credential substring but hold no secret. A token expiry time is | ||
| # what you want to see when debugging "my cloud integration stopped working", so keep it. | ||
| SECRET_KEY_EXEMPT_SUFFIXES = ("_expires_at",) |
…wording - get_log's max_lines described its output as "most recent first"; the tool keeps the most recent matches but returns them oldest-first. Corrected in the tool schema, the tool description and the docs. - Tool arguments were coerced with bare int()/float()/re.search(), so a bad value surfaced as a raw Python exception string. Added parse_number_argument and compile_filter_argument, which raise a named MCPArgumentError, and applied them across get_state, get_log, get_apps, get_config and get_entities so the whole tool surface reports argument errors alike. The last three had carried the unvalidated regex since before this branch; compiling once also drops ~370 re.search calls per get_state. - read_predbat_log used the platform default encoding, so one non-UTF-8 byte in predbat.log would raise UnicodeDecodeError and take out both /api/log and get_log. Now decoded as UTF-8 with errors="replace". Pre-existing in web.py; centralising it is what makes it a one-line fix. - Widened SECRET_KEY_EXEMPT_SUFFIXES to cover _expiry/_expires/_expiration/ _birth. The review's specific examples (Axle partner_token_expiry, Ohme _token_birth) are component attributes that a debug dump never walked, but the exemption is right for that shape of name if one ever moves onto the base. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks — five of the six findings were good and are fixed in 15d4f66. One I've not actioned, with reasoning below. Fixed
Not actioned: the The claim is that I checked the generalisation rather than just the two examples: scanning every The underlying concern about substring matching is still fair as future-proofing, so Verification: |
This is an automated draft PR generated from issue #4768 — a maintainer should review it before merging.
Fixes #4768
Summary
The MCP server already covered part of #4768 via
get_apps/get_config, but had no way to reachpredbat.logor Predbat's internal state — the artefacts a bug report normally has to carry. This adds both, and closes the credential leak that made pointing a cloud AI atget_appsunwise.get_logServes
predbat.log(plus the rotatedpredbat.1.log), with optionalfilter(all/info/warnings/errors, defaultwarnings),search,hoursandmax_lines. Lines come back oldest-first with severity tagged;max_linesdefaults to 500 and is capped at 5000 so a request can't pull a 10MB log through the protocol. Continuation lines with no timestamp of their own (tracebacks) inherit the timestamp of the entry they belong to, sohourskeeps or drops a multi-line entry as a whole.get_stateDeliberately not a debug-yaml download. Sizing a real dump (
coverage/cases/predbat_debug_agile1.yaml, 313 top-level keys, 5.3MB) shows 272 of those keys are under 1KB and total under 10KB between them, while 11 keys are 85% of the file and every one is a per-minute series. So the split is:get_statereturns every variable within the per-variable budget — 371 keys / ~2.4KB (~590 tokens) on a fresh instance — and describes the rest.omittedsection carrying type, length, sample keys and min/max/mean, so the caller can ask for one by name rather than guessing. A size guard, not a size limit.keys,filter(regex) andmax_bytesnarrow or widen the request. Collections over 200 entries are refused on entry count alone, so a 2,880-entry per-minute dict is never serialised just to discover it doesn't fit.json.dumpscan't encode (datetimes, inverter objects) are coerced rather than failing the call.Credential handling
get_appsnow redacts by default, matching what the web UI's own apps.yaml download already did. Passmasked: falseto opt out. Previously_execute_get_appsreturnedself.base.argsverbatim.mask_secret_argswidened to matchsecretandtokenalongside_key/password.sigenergy_app_secret,solis_api_secret,deye_app_secret,alphaess_app_secret,solis_access_token,gateway_mqtt_tokenandmcp_secretwere all being served in the clear.*_expires_atis exempt — a token expiry is exactly what you want visible when debugging a dead cloud integration.utils.is_debug_excluded_keyand is now shared withcreate_debug_yaml, soget_statecan never return anything a debug dump would not (ha_interface,components,secrets, the URL caches,db*, credentials). Verified against a live instance: the widened rule newly excludes zero attributes on a baseline config, so it costs debug dumps nothing and only bites on cloud-integration credentials.Shared filtering
Log level rules (
classify_log_line/log_line_included) are now shared betweenget_logand/api/logso the two views of the same log can't drift. The web handler's behaviour is unchanged — its HTML escaping and search highlighting are pinned by a regression test.Docs
docs/components.mdlists every MCP tool in a table, documentsget_log's andget_state's arguments and the redaction behaviour, and adds a short "ask an AI assistant to review your setup" section describing the workflow the issue asked for.Testing
apps/predbat/tests/test_web_mcp.py(registered asweb_mcp), 10 groups:mask_secret_argskey matching,is_debug_excluded_key,read_predbat_logrotation, the shared filter helpers and timestamp parsing, theget_statevalue helpers,get_appsredaction,get_log,get_state,tools/listregistration, and a regression test that/api/logstill filters, escapes and highlights as before.get_statewas probed against a realPredBatinstance, not just a fake. That surfaced a genuine bug the fake missed:measure_state_valueusedNoneas its "too large" sentinel, so the 14 state variables that are legitimatelyNonewere being reported as omitted. Fixed by returning an explicitfitsflag, with a regression test.tools/triage_test.sh web_mcp— passed.tools/triage_test.sh debug_cases— passed (run explicitly sincecreate_debug_yaml's filter changed; it's marked slow and skipped by--quick).cd coverage && ./run_pre_commit— all hooks passed, and therun_all --quicksuite it runs passed in full.interrogatefails at 89% onmaintoday and is not a pre-commit hook; changed files each moved up or stayed level, and everything added here is documented.Notes
No debug-yaml download tool. An MCP server cannot write to the client's filesystem and everything it returns lands in the client's context, so a 5MB dump has no viable path through the protocol;
get_statereaches the same data incrementally. The "button in the web UI" form of the request needs a billing/key-custody decision and is left for @springfall2008.🤖 Generated with Claude Code