Skip to content

feat!: one error envelope and fixed error vocabulary for all native tools - #49

Merged
Arthurvdv merged 4 commits into
mainfrom
feat/41-error-envelope
Oct 4, 2026
Merged

Arthurvdv merged 4 commits into
mainfrom
feat/41-error-envelope

Conversation

@Arthurvdv

Copy link
Copy Markdown
Member

Summary

All five native tools (analyze, list_rules, get_fixes, apply_fix, apply_fix_all) now report failures the same way: the MCP result has isError: true and its text block holds one ToolError envelope { error, message, reason?, candidates?, filePath?, diagnosticId?, detail? } with a fixed vocabulary. An agent can decide between "fix the call", "retry", "pick a candidate" and "stop" from error and reason alone instead of parsing prose.

error Meaning Agent reaction reason
Invalid arguments unusable as given fix the call –
NotFound nothing matched NoDiagnosticAtPosition → re-run analyze; NoFixForEquivalenceKey → pick from candidates; otherwise stop NoFixProvider, FileNotInProject, NoAnalyzerForRule, SuppressedByRuleset, SuppressedByPragma, NoDiagnosticAtPosition, NoFixForDiagnostic, NoFixForEquivalenceKey
Ambiguous several distinct fixes; candidates lists { equivalenceKey, title, providerName } pass one key verbatim or ask –
Stale file changed or deleted after the fix was computed; nothing written re-run –
Unavailable dependency down report, do not loop NoProxy, AlmcpNotFound, AlmcpNotReady, AlmcpCallFailed
Faulted unexpected exception, or a refused/failed write report with message UnsupportedEncoding, ReadFailed, WriteFailed for writes; detail = exception type

Also in this PR:

  • get_fixes returns { diagnosticId, filePath, line, column, fixes: [...] } with a never-empty fixes; each former "empty array" case is a NotFound with a reason. Ruleset suppression is checked before analysis; pragma suppression is told apart from "no diagnostic here" because the SDK keeps pragma-suppressed diagnostics flagged IsSuppressed rather than dropping them.
  • Equivalence keys round-trip. A null key was advertised as "" by get_fixes but compared against null by apply_fix / apply_fix_all, so such a fix could never be applied. Both sides now go through the same normalization, and candidates carry title and provider so no second get_fixes call is needed.
  • list_rules catches exceptions (an unhandled exception used to become the SDK's generic error text) and validates an explicit projectPath against the startup projects exactly like analyze (closes list_rules should reject an unknown projectPath like analyze does #33). The message for an empty project list no longer renders as started with: . Pass one of those.
  • The three fix tools return Invalid for a projectPath that does not exist or holds no app.json, instead of a raw FileNotFoundException.
  • analyze distinguishes NoProxy, AlmcpNotFound, AlmcpNotReady and AlmcpCallFailed; almcp's own text goes into detail.
  • README gains an ## Errors section; AGENTS.md "Tool patterns" describes the envelope, the isError rule and the ToolErrors / ToolResults / ProjectScope helpers.

Breaking changes

Pre-1.0, shipped as an alpha prerelease from main.

  • All native tool error codes are renamed: StaleFile → Stale; UnsupportedEncoding / ReadFailed → Faulted with reason; NoFixFound / NoFixAvailable → NotFound with reason; AmbiguousFix + availableEquivalenceKeys → Ambiguous + candidates; InvalidScope / MissingFilePath / InvalidLimit / UnknownProject / NoProject → Invalid; ProxyUnavailable / ProxyCallFailed → Unavailable with reason; raw exception type names → Faulted with detail.
  • Every error sets MCP isError: true. Success results never set it. The full JSON stays in the text block.
  • get_fixes success is an object, not a bare array; fixes[] entries are { equivalenceKey, title, providerName } (no diagnosticId per entry).
  • apply_fix_all with a rule the project ruleset sets to None is NotFound / SuppressedByRuleset instead of a diagnosticsFound: 0 success. Zero genuine occurrences remain a success.
  • list_rules rejects an explicit projectPath that is not one of the startup projects (Invalid).
  • apply_fix_all's conflicts[].kind values are unchanged.

Deviations from the issue text

  • The issue listed isError as out of scope; it is set here by decision.
  • SuppressedByRulesetOrPragma is split into SuppressedByRuleset and SuppressedByPragma.
  • NoDiagnosticsForRule is dropped: zero occurrences in apply_fix_all stays a success (ruleset-suppressed excepted).
  • Two reasons were added: NoFixForDiagnostic (keeps fixes never empty on success) and NoFixForEquivalenceKey (with candidates).
  • FixCandidate is folded into CodeFixInfo, so fixes[] and candidates[] are the same type by construction.
  • ToolErrors builds CallToolResult, not strings.

Verification

  • dotnet build --configuration Release and dotnet test --configuration Release pass locally with the almcp-backed tests running (not skipped).
  • Every NotFound and Unavailable reason, every Invalid source, Ambiguous with candidates, Stale, Faulted (exception and each write kind) and the key round trip through get_fixes → apply_fix and apply_fix_all → apply_fix_all are covered by tests. ToolErrorsTests pins the envelope key order and that null fields are omitted.
  • New fixture files under tests/.../Fixtures/PragmaProject/ exercise SuppressedByPragma with a control page.

Review

Three rounds of code-review (high) on the branch before opening the PR. Confirmed findings were fixed in the two follow-up commits; the rest are listed here for the record.

Fixed (confirmed)

  • Malformed projectPath / filePath / folderPath strings (empty, embedded NUL, unsupported format) surfaced as Faulted with System.ArgumentException instead of Invalid. All five tools now normalize caller paths through one helper and return Invalid.
  • The fix tools did not validate filePath at all (round 2).
  • Three test sources contained literal NUL bytes inside string literals, which made git treat them as binary; replaced with \0 escapes.

Adopted from the plausible list

  • FindDiagnosticAsync tries an exact line/column match in both the live and the pragma-suppressed set before any same-line fallback, so a request at a suppressed column is not answered with a live neighbour on the same line.
  • apply_fix says when a key advertised by get_fixes matched but produced no change in the document (the fix may edit another file, which is Fewer round trips for fixes: optional equivalenceKey, dryRun with diff, multi-document changes written #43's scope).
  • The three fix tools echo the same normalized absolute filePath in every result; apply_fix_all in document scope reports a file outside the project as NotFound / FileNotInProject instead of zero occurrences.
  • analyze puts almcp's text in message as well as detail for AlmcpCallFailed.

Carried, not changed (design calls)

  • A failed and rolled-back apply_fix_all batch is a non-error result (applied: false, failure in message, every staged file in conflicts), because the per-file outcome lives in the body and a partial skip is also a success with conflicts. Documented in README and AGENTS.md. A reviewer suggested Faulted when nothing was written; open for discussion.
  • Faulted exposes ex.Message and the exception type in detail. Chosen on purpose; the server is local stdio.
  • Any IsSuppressed diagnostic is described as pragma-suppressed. AL has only #pragma warning disable; the wording can be widened if the SDK adds other suppression sources.
  • get_fixes lists every registered action while candidates are de-duplicated by key (first wins). Same element type, documented on CodeFixInfo and in README.
  • CollectDiagnosticsForRuleAsync / FindRemainingDiagnosticsAsync still filter by ruleset, which is redundant now that apply_fix_all checks it up front; left for Code-fix pipeline robustness: surface analyzer crashes, bound analyzer time, cache analysis per compilation #44, which owns those blocks.
  • FixAllContext still receives "" for a null-key action; a FixAllProvider comparing against null may decline and fall back to the per-document path, which now matches correctly. No ALCops provider in the fixtures has a null key.

Round 3 (final, whole branch): no confirmed findings. One plausible item was fixed in the last commit because it is an inconsistency inside this PR's own contract: apply_fix_all reported zero occurrences for a rule no loaded analyzer reports, while get_fixes and apply_fix say NotFound / NoAnalyzerForRule; all three now agree. That commit was reviewed by the author only. Remaining round-3 items, carried:

Related

🤖 Generated with Claude Code

Arthurvdv and others added 4 commits October 4, 2026 10:31
…ools

All five native tools return CallToolResult with IsError = true on failure and a
single ToolError envelope { error, message, reason?, candidates?, filePath?,
diagnosticId?, detail? } with the fixed codes Invalid, NotFound, Ambiguous, Stale,
Unavailable and Faulted. get_fixes returns an object with the fixes array; its
no-match cases become NotFound with a reason (incl. ruleset vs pragma suppression).
Ambiguous carries candidates with key, title and provider; equivalence keys round
trip (null keys were advertised as "" but never matched). list_rules catches
exceptions and validates projectPath like analyze (closes #33). The empty project
list message is fixed.

BREAKING CHANGE: error codes renamed, get_fixes success shape changed, isError set.

Closes #41
Closes #33

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…n match first, precise no-change message)

Malformed projectPath/filePath/folderPath strings are reported as Invalid instead of
Faulted. FindDiagnosticAsync tries an exact line/column match in both the live and the
pragma-suppressed set before any same-line fallback, so a suppressed position is not
answered with a neighbouring live diagnostic. apply_fix says when a matching key
produced no change in the document. README and AGENTS.md note that a rolled-back
apply_fix_all batch is a non-error result with applied false.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… FileNotInProject for document scope)

The fix tools validate filePath like projectPath, so a malformed path is Invalid, and
echo the normalized absolute path in every result. apply_fix_all in document scope
reports a file outside the project as NotFound/FileNotInProject instead of zero
occurrences. analyze puts the almcp text in the message as well as in detail. Test
sources no longer contain literal NUL bytes. Doc comment on CodeFixInfo corrected.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…instead of zero occurrences)

apply_fix_all checked for a fix provider and for ruleset suppression up front but not
for a loaded analyzer that reports the rule, so a rule whose analyzer is not configured
came back as a success with zero occurrences while get_fixes and apply_fix report
NotFound/NoAnalyzerForRule. The three tools now agree. README and the tool description
name the second exception.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@Arthurvdv
Arthurvdv merged commit c1ac06b into main Oct 4, 2026
11 checks passed
@Arthurvdv
Arthurvdv deleted the feat/41-error-envelope branch October 4, 2026 09:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

One error envelope and a fixed error vocabulary for all native tools list_rules should reject an unknown projectPath like analyze does

1 participant