Add structured error responses with JSON envelopes#9
Conversation
Replace all 35 format_error() return sites with structured JSON error envelopes raised via ToolError, following the mcp-awareness pattern. Error responses now include: - isError=true flag (MCP protocol level) - code: semantic error type (not_found, permission_denied, etc.) - message: human-readable with operation context - retryable: whether the AI should retry - suggestion: actionable fix guidance - help_url: Synology KB or Wikipedia reference Changes: - core/errors.py: add help_url, error_code, retryable to hierarchy - core/formatting.py: add error_response() and synology_error_response() - All 7 tool handler files: replace return format_error() with raises - All test files: update assertions to pytest.raises(ToolError) - 8 new tests for error helper functions Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
|
Adding QA Active — beginning review of structured error envelope refactor. CI is green (lint, typecheck, tests on 3.11/3.12/3.13). Will report findings shortly. |
cmeans
left a comment
There was a problem hiding this comment.
QA Review — Round 1
Verdict: QA Failed — 3 substantive findings (no blockers, but per repo convention doc drift / cross-call-site inconsistency must be resolved in the same PR cycle).
Test plan verified
- 270 tests pass (262 existing + 8 new) — confirmed:
270 passed, 94 deselected, 6 warnings in 5.62s - Lint, format, typecheck clean —
ruff check(All checks passed!),ruff format --check(58 files already formatted),mypy src/(no issues found in 27 source files) - Error responses are valid JSON with required fields — verified via
tests/core/test_formatting.py::TestErrorResponseand integration assertions intests/test_integration.pythatjson.loads(str(exc_info.value))succeeds and containsstatus+error.code+error.message+error.retryable -
help_urlpresent for typed errors — verified viaTestSynologyErrorResponse::test_includes_help_url
Findings
1. [substantive] PR body error-code table is incomplete
The table in the PR description omits two error codes that the new code can actually return:
api_not_found— raised byApiNotFoundErrorfor DSM common error code 102 (errors.py:53–57)filestation_error— raised byFileStationErrorbase when an unmapped File Station code is encountered (errors.py:60–63, returned byerror_from_code()aterrors.py:233)
Per the repo's doc-drift policy this is substantive, not observation — the table is the contract clients rely on, and an undocumented code will be silently dropped by anything that switches on code. Please add both rows (and decide their retryable semantics — currently both default to False).
2. [substantive] unavailable code has inconsistent retryable semantics across call sites
The same error code is emitted with two different retryable values depending on which module raises it:
src/mcp_synology/modules/system/info.py:64→retryable=True("No system information available")src/mcp_synology/modules/system/info.py:126→retryable=True("No system information returned")src/mcp_synology/modules/system/utilization.py:162→retryable=False("No utilization data returned")
A smart client keying off retryable will get contradictory behavior from the same code. Pick one semantic (I'd argue retryable=True matches the PR body table and is the safer default — the API responded but populated nothing, which is more likely transient than permanent), and apply it consistently. Note also the PR body table lists unavailable: yes, so utilization.py:162 is doubly wrong against the documented contract.
3. [substantive] download_file reports the same disk-full condition with two different codes
Two code paths in src/mcp_synology/modules/filestation/transfer.py detect "out of local disk space" but disagree on how to report it:
- Pre-flight check (
transfer.py:161) →code="disk_full",retryable=True - OSError fallback when the error string contains
"space"(transfer.py:200) →code="filesystem_error",retryable=False, with a "Free space on the local disk" suggestion
The fallback's suggestion explicitly tells the user it's a disk-space problem, but the structured code says it isn't, and retryable=False will prevent automated clients from retrying after a cleanup. Either:
- (a) emit
code="disk_full",retryable=Truewhenever the heuristic says it's a space issue, or - (b) leave the code as
filesystem_errorbut change the suggestion + add a comment explaining why we can't trust the OSError text enough to commit todisk_full.
Option (a) is cleaner and matches the pre-flight branch.
Observations (non-blocking)
4. [observation] Consider centralizing error code names
timeout, unavailable, filesystem_error, dsm_error are bare-string literals scattered across operations.py, metadata.py, transfer.py, info.py, utilization.py. Typed-class codes live in errors.py. A typo ("filesytem_error") at any of these call sites would silently produce a non-documented code that clients can't dispatch on, and there's no compile-time check tying them to the PR body table.
A Final[str] constants block (or class ErrorCode(StrEnum)) in errors.py would prevent drift, make the supported set self-documenting, and would have caught finding #1 mechanically. Worth tracking but not a blocker for this PR.
5. [nit] error_response(value=...) accepts non-JSON-serializable types
core/formatting.py:165 types value as Any | None = None, and error_response() calls json.dumps(...) at line 198 without a default= handler. All current callers pass strings, so this is dormant — but a future caller passing bytes or a custom object would crash with TypeError mid-error-handler instead of producing a proper envelope. Consider narrowing the type, or json.dumps(..., default=str) for safety.
Code review notes (positive)
- The
try/finallycleanup for background tasks (CopyMove, Delete, DirSize, Search) is well-disciplined —_stop_background_task()and friends always run, and they log warnings rather than swallowing failures silently. Good defensive structure. synology_error_response()correctly maps the typed exception hierarchy to structured fields, including the DSM numeric code in the human-readable message but keeping thecodefield as the symbolic identifier. The split is the right one.- New
tests/core/test_formatting.py::TestErrorResponsecleanly covers the required-fields/optional-fields/None-omission matrix. - Integration tests in
tests/test_integration.pyuse thejson.loads(str(exc_info.value))pattern consistently, exercising the wire format end-to-end. Good — these will catch any future regressions in the envelope shape.
Re-review requested after the 3 substantive findings are addressed.
|
QA audit — Round 1 complete Posted formal review with 3 substantive findings + 2 observations. Test plan checkboxes updated (all 4 verified locally on Findings summary:
Test results (local on engineering10):
Applying QA Failed as the final act. |
The previous help_url fields pointed at Synology KB pages that mostly
did not match the error semantics (e.g. auto-block page for bad
credentials, FileStation/connect page for "file not found"). Replace
them with a central HELP_URLS registry in core.errors keyed on error
code, pointing at per-section anchors in a new docs/error-codes.md.
The doc is owned by this repo, so every section can give mcp-synology-
specific guidance ("pass overwrite=true", "run mcp-synology check -v")
that vendor docs can never provide. Sections use the literal error code
as their H2 heading so URL anchors match 1:1 with the code field in the
error envelope.
Also:
- error_response() now looks up HELP_URLS[code] when no explicit
help_url is passed, so callers do not need to know URLs.
- Explicit help_url argument still wins for per-call-site overrides.
- SessionExpiredError is intentionally omitted from HELP_URLS (auto-
retried at the core client layer, should never be surfaced).
- New tests/core/test_help_urls.py validates every registered code has
a matching ## anchor in error-codes.md, every URL points at the
troubleshooting doc base, anchors equal error codes, no orphan
sections exist, and every SynologyError subclass is either covered or
explicitly exempted.
- test_formatting.py updated for the new auto-population behavior:
previously-implicit "no help_url" case now uses an unregistered code,
plus new tests for auto-population and explicit override.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Findings from QA Round 1 (all substantive): 1. PR body error-code table was missing api_not_found and filestation_error. Table update will be applied via gh pr edit in the same push. filestation_error now has a dedicated section in docs/error-codes.md covering the unmapped-code fall-through path (common codes like 402 "System too busy", 900 "Unexpected server error") with a link to the Synology API guide for lookup. 2. unavailable had inconsistent retryable semantics: info.py used retryable=True at both call sites but utilization.py used retryable=False. PR body table says yes. Fixed utilization.py (modules/system/utilization.py:162) to retryable=True with a comment explaining the rationale (transient, service warming up after reboot, monitor not ready). 3. download_file reported local-disk-full with two different codes: the pre-flight branch used disk_full/retryable=True but the OSError fallback used filesystem_error/retryable=False despite emitting a "Free space on the local disk" suggestion. Fixed modules/filestation/transfer.py:200 to emit disk_full/retryable=True when e.errno == errno.ENOSPC, matching the pre-flight branch. Also replaced the fragile "if 'space' in error_str.lower()" heuristic with errno.ENOSPC detection. String matching on error messages is locale-dependent and brittle across OS versions. Observation #4 (centralize code names): added class ErrorCode(StrEnum) in core/errors.py with every code the server can emit. Every SynologyError subclass now sets error_code to an enum member, and every bare-string error_response(code="...") call site has been updated to pass ErrorCode.X. HELP_URLS is now built via a comprehension over ErrorCode members, so adding a new code automatically registers a help URL and the test enforces coverage. StrEnum members are strs, so json serialization, dict lookups, and equality against string literals all keep working without conversion. Observation #5 (json.dumps safety): added default=str to the json.dumps call in error_response() so a future caller passing a non-serializable value type gets a best-effort string conversion instead of crashing mid-error-handler. Additional coverage tests in tests/core/test_help_urls.py: - TestErrorCodeEnumCoverage.test_every_enum_member_is_registered_or_exempt ensures every new ErrorCode member either has a HELP_URLS entry or is explicitly exempted. - test_help_urls_keys_are_all_valid_error_codes catches accidental string drift in HELP_URLS keys (typos, renamed codes). EXEMPT_CODES now holds only SESSION_EXPIRED — filestation_error is covered by the new docs section, so the exemption is gone. Not addressed in this round (noted for follow-up): - F18: operations.py:255 and :361 emit bare dsm_error when a background task (copy/move/delete) returns an error dict. The dict often contains a known FileStation code (1100 = insufficient filesystem permissions) that could be routed through error_from_code() for a more specific code. Changes error-response semantics QA should review separately. - F17: several error_response() call sites omit param/value fields. Not a regression, just missed opportunity for structured dispatch. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Round 2 — QA findings addressedAll three Round 1 substantive findings and both observations are resolved in Findings
Observations
Local verification
Unaddressed (noted for follow-up, not in this round)
Ready for QA Round 2 review. |
The structured error refactor introduced new error branches that were not exercised by the existing test suite, and the system modules were essentially untested (13% coverage each). Codecov flagged 23 missing patch lines. This commit closes that gap. New test file: tests/modules/system/test_info.py (7 tests) - Happy path with both SYNO.DSM.Info and SYNO.Core.System populated - Temperature warning flag rendering - DSM.Info only (Core.System absent from API cache short-circuits) - Both sources fail → unavailable, retryable=True - Both sources return empty data → unavailable, retryable=True - Uptime formatting (days/hours/minutes) - Uptime under 60 seconds Module coverage: 13% → 99% New test file: tests/modules/system/test_utilization.py (6 tests) - Happy path with full CPU/memory/network/disk payload - Disk as bare list (alternate DSM response shape) - api_not_found when SYNO.Core.System.Utilization missing from cache - DSM code 105 maps to permission_denied (admin required) - Other DSM errors propagate via synology_error_response - Empty data payload → unavailable, retryable=True (finding #2 regression test) Module coverage: 13% → 93% test_metadata.py additions (4 tests) - get_file_info with empty files list returns not_found - get_dir_size start error (DSM code 408 → not_found) - get_dir_size poll error mid-operation (DSM code 402 → filestation_error, also exercises the try/finally cleanup path) - get_dir_size timeout with tight 1s budget Module coverage: 86% → 96% test_operations.py additions (5 tests, new TestBackgroundTaskErrors class) - Copy timeout (status never returns finished) - Copy task completes with error dict → dsm_error with embedded DSM code - Copy poll error mid-operation (DSM code 402) - Delete timeout - Delete task completes with error dict Module coverage: 79% → 91% test_transfer.py additions (2 tests) - download_file OSError with errno=ENOSPC → disk_full, retryable=True (direct test for the Round 2 errno-based detection introduced by finding #3 / F19) - download_file OSError with non-ENOSPC errno (EACCES) → filesystem_error, retryable=False (covers the fall-through branch) Module coverage: 84% → 85% Overall patch coverage recovered to >90% on the modified files. Total project coverage: 73% → 80%. Tests: 281 → 305, all passing. No source changes — this commit is pure test additions. The ENOSPC test uses monkeypatch.setattr to replace client.download with an async function that raises OSError(errno.ENOSPC, ...). Using monkeypatch rather than a real disk-full condition because we cannot reliably create ENOSPC in a sandboxed test environment, and the behavior we care about is the error-dispatch logic in the except clause, not the underlying OS behavior. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Round 3 — Test coverage recovery (
|
Three targeted tests for the branches that weren't exercised: 1. CPU other_load fallback format (lines 49-51): DSM payload where CPU reports only ``other_load`` without 15min_load or system/user split. Triggers the ``elif "other_load" in cpu:`` branch in _format_cpu. 2. Memory cached + swap-in detail (lines 73, 76): payload with ``cached`` and ``si_disk`` populated. Covers both conditional extensions in _format_memory — the "cached" detail append and the "Swap in" pair. 3. Disk as unexpected type (line 156): payload with disk=<string>, which is neither dict nor list. Exercises the ``else: disk_list = []`` fallback, confirming the tool renders without disk entries instead of raising. utilization.py: 92 stmts, 0 missing, 100% coverage. Project total: 89% → 90%. Tests: 305 → 308. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Round 2 response — both items addressed (`45e29f7`)Finding #1 (substantive) — PR body test count staleUpdated the PR body test plan from "281 tests pass" to "312 tests pass" (matches Observation #2 (nit) — Tighten `error_response(code)` typeAdopted. `core/formatting.py:160` — `code: str` → `code: ErrorCode`. A typo at a call site ("not_foundd") is now a mypy error rather than a silent envelope with a missing help_url. This closes the one drift direction the `tests/core/test_help_urls.py` invariants can't catch (they validate the `ErrorCode ↔ HELP_URLS ↔ docs` triangle, but they can't see call sites passing bare strings). The tightening had one small test-plumbing consequence: `test_omits_optional_fields_when_none` previously used a bogus string literal ("zzz_unregistered_test_code") to exercise the "no help_url field" omission path. Under `code: ErrorCode`, that test can only be expressed using the one ErrorCode member intentionally absent from HELP_URLS — `ErrorCode.SESSION_EXPIRED`. Test rewritten to use it, with a comment explaining why that specific member is the right choice under the tightened signature. The test still covers the same branch (the "no help_url" path in `error_response()`), just via a legitimate code. All 18 `error_response()` call sites in `src/` were already using `ErrorCode.X` from the Round 1 centralization work, so no source call sites needed to change — only the function signature and the test file. Verified locally on `45e29f7`
Ready for QA Round 3 / Signoff. |
|
Adding QA Active — beginning Round 3. Dev addressed the test count drift AND voluntarily tightened |
cmeans
left a comment
There was a problem hiding this comment.
QA Review — Round 3
Verdict: Ready for QA Signoff. All Round 1 and Round 2 findings resolved. Architecture, testing, docs, and signature discipline are in good shape. Awaiting maintainer signoff.
Round 2 findings — both resolved
1. PR body test count drift (substantive): Fixed. Test plan now reads 312 tests pass and embeds the exact verification command (uv run pytest: 312 passed, 94 deselected). PR body and reality are back in sync.
2. error_response(code: ErrorCode) signature (observation): Fixed — and addressed proactively, not just patched. src/mcp_synology/core/formatting.py:160-170 now types code as ErrorCode rather than str. The docstring at lines 177-180 explicitly explains the rationale: "a typo at a call site becomes a mypy error rather than a silent envelope with a missing help_url. StrEnum members are strings at runtime, so JSON serialization and dict lookups still work unchanged." That's the reasoning I would have written.
The implementation correctly uses code.value at the two serialization points (error["code"] = code.value at line 195, HELP_URLS.get(code.value) at line 208) so the StrEnum doesn't bleed into the JSON or dict-key namespaces. mypy clean across all 27 source files confirms every existing call site already passed an ErrorCode member — the signature change was a tightening with zero migration work, exactly the right shape.
New tests added in Round 3
test_help_url_auto_populated_from_registry(tests/core/test_formatting.py:208) — verifies that callingerror_response()without an explicithelp_urlresolves the URL from the registry automatically.test_help_url_explicit_override_wins(tests/core/test_formatting.py:217) — verifies the explicit-override path so future callers can point at custom URLs.test_omits_optional_fields_when_none— cleverly rewritten to useErrorCode.SESSION_EXPIRED(the only intentionally-exempt code) so the "no help_url field" assertion is exercised on the one code where that omission is real, rather than a synthetic non-existent code. The comment at lines 193-196 spells this out.
Test plan verified (fresh in this session, commit 45e29f7)
- 312 tests pass —
uv run pytest:312 passed, 94 deselected, 7 warnings in 17.04s - Lint, format, typecheck clean —
uv run ruff check .(All checks passed!),uv run ruff format --check .(62 files already formatted),uv run mypy src/(Success: no issues found in 27 source files) - Error responses are valid JSON with required fields —
tests/core/test_formatting.py::TestErrorResponse(5 cases including the new auto-populate and override paths) -
help_urlpresent for every typed error and auto-resolved fromHELP_URLS—tests/core/test_help_urls.py::TestHelpUrlsResolveToRealAnchors(4 invariants) -
docs/error-codes.mdhas a## <code>heading for everyErrorCodemember except explicit exemptions —TestErrorCodeEnumCoverage::test_every_enum_member_is_registered_or_exemptandtest_no_orphan_sections -
error_response(code: ErrorCode)rejects bare strings — enforced by mypy; verified clean run onsrc/ - Patch coverage ≥90% on every modified module — Dev verified via codecov in Round 2 coverage commits
CI status
All required checks green:
lint— SUCCESStypecheck— SUCCESStest (3.11)— SUCCESStest (3.12)— SUCCESStest (3.13)— SUCCESSqa-approved(QA Gate) — SUCCESSon-push— SUCCESS
Summary
Three rounds of QA, every finding resolved, every observation adopted (often with a stronger fix than what I asked for). The end result is a substantially better error-handling architecture than the starting point: typed envelopes, centralized enum, auto-populated help URLs, errno-based ENOSPC handling, multi-invariant CI-enforced drift test, 325-line per-code reference doc, and a tight code: ErrorCode signature that prevents typo regressions at compile time.
Applying Ready for QA Signoff as the final act. Awaiting maintainer review for QA Approved.
|
QA audit — Round 3 complete (clean) All Round 1 and Round 2 findings resolved. Dev addressed the substantive count drift AND voluntarily tightened Test results (fresh in this session, commit 45e29f7):
Three rounds, every finding resolved, every observation adopted with stronger fixes than asked. The errno-based ENOSPC handling, multi-invariant drift test, and Applying Ready for QA Signoff as the final act. Awaiting maintainer review for QA Approved. |
Minor bump for the structured error responses landed in #9. Version is minor rather than patch because the error wire format is a client-visible behavior change: tool failures now raise ToolError with a JSON envelope (code, message, retryable, suggestion, help_url) and isError=true at the MCP protocol level, rather than returning human-readable strings. Clients keying off isError get proper failure signaling for the first time; clients pattern-matching the old text format ("[!] ... failed:") will need to update. Files touched: - pyproject.toml: 0.4.1 → 0.5.0 - server.json: both version fields bumped (top-level + pypi package) - uv.lock: re-resolved via ``uv lock`` - CHANGELOG.md: 0.5.0 entry documenting the change, the new ErrorCode enum / HELP_URLS registry / docs/error-codes.md drift test, the errno.ENOSPC fix, the unavailable retryable consistency fix, and the new system-module test coverage Local verification on release/v0.5.0: - uv run pytest -q: 312 passed, 94 deselected - uv run ruff check src/ tests/: clean - uv run ruff format --check src/ tests/: clean - uv run mypy src/: no issues found in 27 source files Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Bump version to 0.5.0 Minor bump for the structured error responses landed in #9. Version is minor rather than patch because the error wire format is a client-visible behavior change: tool failures now raise ToolError with a JSON envelope (code, message, retryable, suggestion, help_url) and isError=true at the MCP protocol level, rather than returning human-readable strings. Clients keying off isError get proper failure signaling for the first time; clients pattern-matching the old text format ("[!] ... failed:") will need to update. Files touched: - pyproject.toml: 0.4.1 → 0.5.0 - server.json: both version fields bumped (top-level + pypi package) - uv.lock: re-resolved via ``uv lock`` - CHANGELOG.md: 0.5.0 entry documenting the change, the new ErrorCode enum / HELP_URLS registry / docs/error-codes.md drift test, the errno.ENOSPC fix, the unavailable retryable consistency fix, and the new system-module test coverage Local verification on release/v0.5.0: - uv run pytest -q: 312 passed, 94 deselected - uv run ruff check src/ tests/: clean - uv run ruff format --check src/ tests/: clean - uv run mypy src/: no issues found in 27 source files Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Remove non-standard Dev changelog category (QA Round 1) QA finding: ``### Dev`` is not a Keep a Changelog category. Standard categories are Added, Changed, Deprecated, Removed, Fixed, Security. Took option (a) from the QA suggestion — delete the section rather than fold its content into Added. Internal metrics (test count deltas, patch coverage percentages, mypy/ruff status) are CI and PR-description concerns, not consumer-facing changelog entries. Operators upgrading from 0.4.1 → 0.5.0 don't need to read them; they care about behavior changes and the migration note, which are already covered under Changed/Added/Fixed. Also fixed the pre-existing ``### Fixes`` → ``### Fixed`` typo in the 0.4.1 entry. QA noted it as a non-blocking pre-existing observation; folding it into this release PR per the project's "any nits should be addressed" convention rather than leaving it for a future cleanup. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: cmeans-claude-dev[bot] <3223881+cmeans-claude-dev[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
## Summary - Closes #30. F18 from the PR #9 self-audit. - `operations.py:256` (copy/move) and `operations.py:365` (delete) previously emitted a bare `DSM_ERROR` envelope when the background task completed with an `error` dict, diverging from synchronous paths in the same module that already route through `error_from_code()` via `synology_error_response()`. - Both task-completion paths now pass the DSM code through `error_from_code(err_code, "SYNO.FileStation.CopyMove" | ".Delete")` and use the mapped exception's `error_code`, `retryable`, and `suggestion` for the envelope (with the previous generic suggestion as fallback). ## Behavior change **Callers catching `dsm_error` on these two paths will now receive the more specific code.** This is the exact change QA flagged as wanting its own review. | DSM code | Before | After | |---------|--------|-------| | 408 | `dsm_error` | `not_found` | | 414 | `dsm_error` | `already_exists` | | 416 | `dsm_error` | `disk_full` (retryable=true) | | 418 / 419 | `dsm_error` | `invalid_parameter` | | 1100 | `dsm_error` | `filestation_error` | | 105 | `dsm_error` | `permission_denied` | | unmapped (e.g. 9999) | `dsm_error` | `dsm_error` (unchanged) | Note on issue AC wording: the issue body uses "e.g. `PERMISSION_DENIED` for 1100" as an example, but `error_from_code(1100, "SYNO.FileStation.*")` returns `FileStationError` (envelope `filestation_error`) — 1100 is FileStation's "insufficient filesystem permissions," not common code 105. The mapping remains more specific than `dsm_error`; broadening 1100 → `permission_denied` is out of scope (it would affect synchronous paths across every FileStation module). Raised for visibility. ## Changes | File | Change | |------|--------| | `src/mcp_synology/modules/filestation/operations.py` | Import `error_from_code`; route `err_code` through it at both task-completion sites; use `mapped.error_code`, `mapped.retryable`, `mapped.suggestion or <generic fallback>`. Message text unchanged (preserves the `DSM error code {err_code} on path: {err_path}` format). | | `tests/modules/filestation/test_operations.py` | Updated `test_copy_task_completes_with_error` + `test_delete_task_completes_with_error` to assert `filestation_error` on code 1100 and the per-code suggestion text. Added four new cases: 408→`not_found` (copy), 416→`disk_full`+`retryable=true` (copy), 105→`permission_denied` (delete), and an unknown-code (9999) fallback case confirming `dsm_error` + generic suggestion. | | `CHANGELOG.md` | New `### Changed` entry under `## Unreleased` (#30). | ## Test assertions that changed Every existing assertion is enumerated here per issue AC: 1. `test_copy_task_completes_with_error` - `body["error"]["code"] == "dsm_error"` → `"filestation_error"` - Added: `"shared folder" in body["error"]["suggestion"].lower()` (per-code mapping now wins). 2. `test_delete_task_completes_with_error` - `body["error"]["code"] == "dsm_error"` → `"filestation_error"` No other existing assertions changed. The timeout / poll-error tests are untouched (those paths do not go through `error_from_code`). ## Test plan - [x] `uv run ruff check src/ tests/` — clean - [x] `uv run ruff format --check src/ tests/` — clean - [x] `uv run mypy src/` — clean (strict) - [x] `uv run pytest` — 499 passed, 96.04% coverage (95% gate) - [x] New failing tests observed (red), then pass after impl (green) ## Out of scope - F17 (param/value) — shipped in #31 - Synchronous error paths — already use `synology_error_response` / `error_from_code` - Rethinking 1100's mapping (FileStation vs common-105 semantics) — separate concern 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: cmeans-claude-dev[bot] <3223881+cmeans-claude-dev[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Summary
format_error()return sites with structured JSON error envelopesisError=trueat the MCP protocol levelcode,message,retryable,suggestion, andhelp_urlErrorCodeStrEnum incore/errors.pyas the single source of truth for every emitted codeerror_response(code: ErrorCode)— signature tightened so call-site typos become mypy errors rather than silent envelopesdocs/error-codes.md(our own reference, not generic Synology KB pages) with a CI-enforced drift checkError envelope format
{ "status": "error", "error": { "code": "not_found", "message": "List files failed (DSM error 408): No such file or directory", "retryable": false, "suggestion": "Use list_files or search_files to find the correct path.", "help_url": "https://github.com/cmeans/mcp-synology/blob/main/docs/error-codes.md#not_found" } }Error codes
auth_failedsession_expiredpermission_deniedapi_not_foundnot_foundalready_existsinvalid_parameterfilesystem_errordisk_fulltimeoutunavailablefilestation_errordsm_errorFiles changed
core/errors.py—ErrorCode(StrEnum),HELP_URLSregistry derived from the enum, typed exception hierarchycore/formatting.py—error_response()/synology_error_response();code: ErrorCodesignature; auto-populatehelp_urlfrom the registry;json.dumps(default=str)safetyErrorCode.Xdocs/error-codes.md— per-code reference page (13 sections, anchor-per-code)tests/core/test_help_urls.py— multi-invariant drift check betweenErrorCode,HELP_URLS, and the docmodules/system/(previously untested, 13% → 100% coverage)filestation/modulesTest plan
uv run pytest:312 passed, 94 deselecteduv run ruff check,uv run ruff format --check,uv run mypy src/tests/core/test_formatting.py::TestErrorResponsehelp_urlpresent for every typed error and auto-resolved fromHELP_URLS—tests/core/test_help_urls.pydocs/error-codes.mdhas a## <code>heading for everyErrorCodemember except explicit exemptions —TestErrorCodeEnumCoverageerror_response(code: ErrorCode)rejects bare strings — enforced by mypy; every src/ call site usesErrorCode.X🤖 Generated with Claude Code