transit branch T-1711 commits 2 files 7 touched lines +207 / -8 review no findings

Pre-push review: T-1711

Exact-head review of ff68c1360c8260a05fb578f367d882568f25c00e against merge base 6dfc0bcda9b301e85512a07cb0c248ec0106e7e3. The change makes MCP get_projects fail closed when scoped milestone enrichment cannot be read.

At a glance

  • Correctness: a scoped milestone fetch error is no longer indistinguishable from an empty milestone list.
  • Atomic response: a failure while enriching a later project returns Failed to fetch milestones: <error>, not a partial successful array.
  • Validated exact head: make lint, make test-quick, and make build passed at ff68c136.
  • Full iOS suite: 1,255 passed and 3 UI tests failed; the same three targeted tests fail at base 6dfc0bc and are unrelated to T-1711.

Verdict

Ready to push

Ready for the T-1711 scope. The exact remote head has no actionable review findings. The injected failure now crosses the service boundary, the MCP handler returns the exact tool error before serializing a partial project list, and every caller of the new throwing API handles the contract deliberately. Repository-health exception: the full iOS result bundle reports three existing UI-test failures; each reproduces unchanged at the merge base, so none is introduced by this branch.

Commits

Three-level explanation

What changed

get_projects used to treat a broken milestone read like a project that simply had no milestones. It now reports that the read failed instead of returning information clients could mistake as complete.

Why it matters

An agent planning work needs trustworthy project metadata. Saying a shelf is empty when the storage room is locked can cause bad routing decisions; returning an error tells the client to retry or surface the problem.

Key concepts

The service layer now throws its storage error. The MCP layer catches it and turns it into the established tool-error response. The editor picker still intentionally shows no options on a read error because it has no error presentation surface.

Changes overview

MilestoneService.milestonesForProject now reads through its injected ModelFetching dependency and declares throws. MCPToolHandler.handleGetProjects replaces map with a loop so it can exit with a tool error before encoding any accumulated dictionaries.

Implementation approach

The regression seam is injected at service construction. Its fetcher returns a valid empty list for project one and throws on project two, proving the handler does not expose the already accumulated result. The normal empty-project and cross-project-scoping behavior is covered separately.

Trade-offs

Making the method throwing requires every caller to declare intent. The authoritative MCP caller propagates failure; the presentation-only task editor explicitly retains its old empty-picker fallback. A second MCP-only query helper was avoided because it would duplicate the scoped fetch and leave the shared API unsafe.

Technical deep dive

The old (try? modelContext.fetch(descriptor)) ?? [] collapsed an availability failure into a domain value and bypassed the existing fetcher seam. The new try fetcher.fetch(descriptor) preserves the storage-failure channel. The handler's local results cannot escape on an enrichment failure because it returns errorResult before IntentHelpers.encodeJSONArray is reached.

Architecture impact

This aligns get_projects with the repository's failure-preserving query adapters and confines presentation degradation to the UI caller that owns it. It adds no network work, caching, or API-schema expansion.

Edge cases and completeness assessment

Covered: first-project empty results, a later scoped failure, exact error text, no partial serialized response, ordinary project scoping, and all compile-time callers. Intentionally retained: an empty TaskEditView picker on storage failure. No T-1711 requirements are partially implemented or missing. The only residual validation issue is the three unrelated baseline UI failures described below.

Important changes — detailed

MilestoneService: preserve scoped storage errors

Transit/Transit/Services/MilestoneService.swift

Why it matters. A failed SwiftData read can no longer masquerade as a valid empty milestone result.

What to look at. MilestoneService.swift:309-320

Takeaway. Authoritative consumers should receive the real failure channel from the shared service API, not a sentinel domain value.
Rationale. The injected ModelFetching seam already exists for scoped lookups and makes storage failure behavior deterministic in tests.

MCP handler: reject incomplete get_projects responses

Transit/Transit/MCP/MCPToolHandler.swift

Why it matters. An agent can no longer treat a partially enriched project list as authoritative metadata.

What to look at. MCPToolHandler.swift:1154-1181

Takeaway. Build an aggregate response incrementally when a later enrichment can fail and the endpoint must be all-or-error.
Rationale. The bugfix report rejects returning already collected projects because clients cannot distinguish a partial response from a complete one.

MCP regression: simulate a later-project failure

Transit/TransitTests/MCPGetProjectsTests.swift

Why it matters. The test proves the critical failure mode that a single-project or first-fetch failure test would miss.

What to look at. MCPGetProjectsTests.swift:10-111

Takeaway. For aggregate queries, make a test fail after at least one valid element has been accumulated to prove response atomicity.
Rationale. The first injected call succeeds empty and the second throws; the assertion verifies the exact MCP error and two fetches.

Task editor: retain explicit presentation fallback

Transit/Transit/Views/TaskDetail/TaskEditView+Milestones.swift

Why it matters. The broader service contract changes without silently changing the existing picker-only UX.

What to look at. TaskEditView+Milestones.swift:11-24

Takeaway. When a shared API becomes failure-aware, each caller should explicitly choose propagation or a documented local fallback.
Rationale. The picker has no storage-error presentation path, whereas MCP is an authoritative automation surface.

Key decisions

Use one throwing scoped fetch instead of an MCP-only helper

The shared service now distinguishes unreadable storage from a legitimate empty collection. A separate MCP helper would duplicate the scoped descriptor and preserve the unsafe failure-collapsing API for other callers.

Return a tool error rather than a partial project array

get_projects is an authoritative routing and planning input. A partial success is indistinguishable from a complete response to a client, so failure must win before JSON serialization.

Per-file diffs

Click to expand.

CHANGELOG.md Modified +1 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 04e4e3a..df586be 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).  ## [Unreleased] +- T-1711: MCP `get_projects` now returns the exact tool error `Failed to fetch milestones: <error>` when a project-scoped milestone fetch fails, rather than a successful partial list that omits milestone metadata. The scoped service fetch preserves its injected storage error; deterministic regressions cover the failure, an empty project's omitted `milestones` field, and correctly scoped milestones for another project. - T-1675: Project-scoped milestone-name lookups now preserve the exact SwiftData fetch failure through shared assignment as `INTERNAL_ERROR` (`Failed to look up milestone: <error>`), matching JSON/MCP create, update, and scoped-query adapters. The milestone service's injected fetcher is now available in MCP test setup; deterministic regressions prove no-match and ambiguity remain distinct, cross-project unscoped filtering is untouched, and failed lookups create or mutate nothing. - T-1825: Dashboard milestone filters now live-observe local, MCP, and CloudKit milestone changes, retaining selected Done or Abandoned rows within the active project scope so they remain visible and individually deselectable. Open rows remain first and follow their displayed title order; terminal selections are appended deterministically and deduplicated by UUID. Selected rows expose the native selected accessibility trait once, while inaccessible or deleted selections still leave Clear available. Add Task remains open-only. 
Transit/Transit/MCP/MCPToolHandler.swift Modified +10 / -3
diff --git a/Transit/Transit/MCP/MCPToolHandler.swift b/Transit/Transit/MCP/MCPToolHandler.swiftindex 88b2ef3..8e177dc 100644--- a/Transit/Transit/MCP/MCPToolHandler.swift+++ b/Transit/Transit/MCP/MCPToolHandler.swift@@ -1158,18 +1158,25 @@ extension MCPToolHandler {         } catch {             return errorResult("Failed to fetch projects: \(error)")         }-        let results: [[String: Any]] = projects.map { project in+        var results: [[String: Any]] = []+        for project in projects {             var dict: [String: Any] = [                 "projectId": project.id.uuidString, "name": project.name,                 "description": project.projectDescription, "colorHex": project.colorHex,                 "activeTaskCount": projectService.activeTaskCount(for: project)             ]             if let gitRepo = project.gitRepo { dict["gitRepo"] = gitRepo }-            let milestones = milestoneService.milestonesForProject(project)++            let milestones: [Milestone]+            do {+                milestones = try milestoneService.milestonesForProject(project)+            } catch {+                return errorResult("Failed to fetch milestones: \(error)")+            }             if !milestones.isEmpty {                 dict["milestones"] = milestones.map { milestoneSummaryDict($0) }             }-            return dict+            results.append(dict)         }         return textResult(IntentHelpers.encodeJSONArray(results))     }
Transit/Transit/Services/MilestoneService.swift Modified +2 / -2
diff --git a/Transit/Transit/Services/MilestoneService.swift b/Transit/Transit/Services/MilestoneService.swiftindex ffa53bb..282d4a9 100644--- a/Transit/Transit/Services/MilestoneService.swift+++ b/Transit/Transit/Services/MilestoneService.swift@@ -306,12 +306,12 @@ final class MilestoneService {         try MilestoneNameReconciler(modelContext: modelContext).reconcile()     } -    func milestonesForProject(_ project: Project, status: MilestoneStatus? = nil) -> [Milestone] {+    func milestonesForProject(_ project: Project, status: MilestoneStatus? = nil) throws -> [Milestone] {         let projectID = project.id         let descriptor = FetchDescriptor<Milestone>(             predicate: #Predicate { $0.project?.id == projectID }         )-        var milestones = (try? modelContext.fetch(descriptor)) ?? []+        var milestones = try fetcher.fetch(descriptor)         if let status {             let statusRaw = status.rawValue             milestones = milestones.filter { $0.statusRawValue == statusRaw }
Transit/Transit/Views/TaskDetail/TaskEditView+Milestones.swift Modified +3 / -1
diff --git a/Transit/Transit/Views/TaskDetail/TaskEditView+Milestones.swift b/Transit/Transit/Views/TaskDetail/TaskEditView+Milestones.swiftindex f9845a8..e78e238 100644--- a/Transit/Transit/Views/TaskDetail/TaskEditView+Milestones.swift+++ b/Transit/Transit/Views/TaskDetail/TaskEditView+Milestones.swift@@ -14,7 +14,9 @@ extension TaskEditView {         milestoneService: MilestoneService     ) -> [Milestone] {         guard let project else { return [] }-        var milestones = milestoneService.milestonesForProject(project, status: .open)+        // The picker has no storage-error presentation path; retain its existing+        // empty-options fallback while authoritative MCP reads propagate failures.+        var milestones = (try? milestoneService.milestonesForProject(project, status: .open)) ?? []          guard let selectedMilestone, selectedMilestone.project?.id == project.id else {             return milestones
Transit/TransitTests/MCPGetProjectsTests.swift Modified +62 / -0
diff --git a/Transit/TransitTests/MCPGetProjectsTests.swift b/Transit/TransitTests/MCPGetProjectsTests.swiftindex 698bdb5..8591b51 100644--- a/Transit/TransitTests/MCPGetProjectsTests.swift+++ b/Transit/TransitTests/MCPGetProjectsTests.swift@@ -7,6 +7,23 @@ import Testing @MainActor @Suite(.serialized) struct MCPGetProjectsTests { +    private struct ScopedMilestoneFetchFailure: Swift.Error, CustomStringConvertible {+        var description: String { "simulated scoped milestone fetch failure" }+    }++    /// Returns a valid empty result for the first project, then fails for the+    /// second. This proves `get_projects` cannot emit the first project as a+    /// partial authoritative response when later enrichment fails.+    private final class FailingAfterFirstScopedMilestoneFetch: ModelFetching {+        private(set) var fetchCallCount = 0++        func fetch<T: PersistentModel>(_ descriptor: FetchDescriptor<T>) throws -> [T] {+            fetchCallCount += 1+            guard fetchCallCount > 1 else { return [] }+            throw ScopedMilestoneFetchFailure()+        }+    }+     @Test func getProjectsReturnsCorrectFieldsAndSortOrder() async throws {         let env = try MCPTestHelpers.makeEnv()         let bravo = MCPTestHelpers.makeProject(in: env.context, name: "Bravo")@@ -45,6 +62,51 @@ struct MCPGetProjectsTests {         #expect(results.isEmpty)     } +    @Test+    func getProjectsMilestoneFetchFailureReturnsExactToolErrorWithoutPartialProjects() async throws {+        let milestoneFetcher = FailingAfterFirstScopedMilestoneFetch()+        let env = try MCPTestHelpers.makeEnv(milestoneServiceFetcher: milestoneFetcher)+        MCPTestHelpers.makeProject(in: env.context, name: "Alpha")+        MCPTestHelpers.makeProject(in: env.context, name: "Beta")++        let response = await env.handler.handle(MCPTestHelpers.toolCallRequest(+            tool: "get_projects", arguments: [:]+        ))++        #expect(+            try MCPTestHelpers.isError(response),+            "A milestone fetch failure must not return a partial project list"+        )+        #expect(+            try MCPTestHelpers.errorText(response)+                == "Failed to fetch milestones: simulated scoped milestone fetch failure"+        )+        #expect(milestoneFetcher.fetchCallCount == 2)+    }++    @Test func getProjectsPreservesEmptyMilestonesAndScopesOtherProjects() async throws {+        let env = try MCPTestHelpers.makeEnv()+        let alpha = MCPTestHelpers.makeProject(in: env.context, name: "Alpha")+        let beta = MCPTestHelpers.makeProject(in: env.context, name: "Beta")+        _ = try await env.milestoneService.createMilestone(name: "Beta 1", description: nil, project: beta)+        _ = try await env.milestoneService.createMilestone(name: "Beta 2", description: nil, project: beta)++        let response = await env.handler.handle(MCPTestHelpers.toolCallRequest(+            tool: "get_projects", arguments: [:]+        ))++        let results = try MCPTestHelpers.decodeArrayResult(response)+        #expect(results.count == 2)++        let alphaResult = try #require(results.first { $0["projectId"] as? String == alpha.id.uuidString })+        #expect(alphaResult["milestones"] == nil)++        let betaResult = try #require(results.first { $0["projectId"] as? String == beta.id.uuidString })+        let milestones = try #require(betaResult["milestones"] as? [[String: Any]])+        #expect(milestones.count == 2)+        #expect(Set(milestones.compactMap { $0["name"] as? String }) == ["Beta 1", "Beta 2"])+    }+     @Test func getProjectsIncludesGitRepoWhenSetAndOmitsWhenNil() async throws {         let env = try MCPTestHelpers.makeEnv()         MCPTestHelpers.makeProject(in: env.context, name: "WithRepo", gitRepo: "https://github.com/org/repo")
Transit/TransitTests/MilestoneServiceLookupTests.swift Modified +2 / -2
diff --git a/Transit/TransitTests/MilestoneServiceLookupTests.swift b/Transit/TransitTests/MilestoneServiceLookupTests.swiftindex 8b6ea85..54709f3 100644--- a/Transit/TransitTests/MilestoneServiceLookupTests.swift+++ b/Transit/TransitTests/MilestoneServiceLookupTests.swift@@ -105,8 +105,8 @@ struct MilestoneServiceLookupTests {         let done = try await service.createMilestone(name: "v2.0", description: nil, project: project)         try service.updateStatus(done, to: .done) -        #expect(service.milestonesForProject(project).count == 2)-        let openOnly = service.milestonesForProject(project, status: .open)+        #expect(try service.milestonesForProject(project).count == 2)+        let openOnly = try service.milestonesForProject(project, status: .open)         #expect(openOnly.count == 1)         #expect(openOnly.first?.name == "v1.0")     }
specs/bugfixes/mcp-get-projects-milestone-fetch-failures/report.md Added +127 / -0
diff --git a/specs/bugfixes/mcp-get-projects-milestone-fetch-failures/report.md b/specs/bugfixes/mcp-get-projects-milestone-fetch-failures/report.mdnew file mode 100644index 0000000..e6d19b1--- /dev/null+++ b/specs/bugfixes/mcp-get-projects-milestone-fetch-failures/report.md@@ -0,0 +1,127 @@+# Bugfix Report: MCP Get Projects Hides Milestone Fetch Failures++**Date:** 2026-08-05+**Status:** Fixed++## Description of the Issue++`get_projects` fetches projects successfully and then enriches each result with+that project's milestones. `MilestoneService.milestonesForProject` turns a+SwiftData fetch error into `[]`, so the MCP handler returns a successful,+partial project list. Clients cannot distinguish an unreadable milestone store+from a valid project with no milestones.++**Reproduction steps:**+1. Inject a `ModelFetching` implementation that returns a valid empty result+   for the first scoped milestone fetch and throws for the second.+2. Call MCP `get_projects` with two or more projects present.+3. Observe a non-error partial project array with missing `milestones` fields+   instead of a tool error.++**Impact:** MCP clients can treat incomplete project/milestone metadata as+authoritative and make incorrect routing or planning decisions.++## Investigation Summary++- **Symptoms examined:** a deterministic injected scoped-fetch failure produced+a successful `get_projects` response.+- **Code inspected:** `MilestoneService.milestonesForProject`,+  `MCPToolHandler.handleGetProjects`, the three SwiftUI picker/filter callers,+  `MCPGetProjectsTests`, and T-1675's merged `ModelFetching` design.+- **Hypotheses tested:** The regression test proved the failure occurs after a+  successful project fetch; valid empty milestone data and separate project+  scopes remain correct.++## Discovered Root Cause++`MilestoneService.milestonesForProject` uses+`(try? modelContext.fetch(descriptor)) ?? []`. This converts a storage failure+into the same value used for a legitimate empty result and bypasses its+injected `ModelFetching` seam, unlike T-1675's scoped name lookup.++**Defect type:** Error-handling/data-integrity defect.++**Why it occurred:** The original non-throwing picker helper was reused by the+MCP response-enrichment path without making the authoritative MCP consumer+handle a failed store read.++## Resolution for the Issue++**Changes made:**+- `MilestoneService.milestonesForProject` is now throwing and reads through its+  existing injected `ModelFetching` seam, preserving storage failures.+- `MCPToolHandler.handleGetProjects` builds results in a loop and returns the+  exact `Failed to fetch milestones: <error>` tool error before it can emit an+  authoritative partial project list.+- `TaskEditView.availableMilestones` explicitly retains its existing empty+  picker fallback because it is a presentation-only surface with no error UI.+- `MCPGetProjectsTests` adds deterministic failure and valid-empty/multi-project+  coverage; `MilestoneServiceLookupTests` adopts the throwing contract.++**Approach rationale:** This follows T-1675: the service layer distinguishes+"could not read" from "nothing matched", while the MCP adapter turns the exact+underlying error into the established tool error wording. An ordinary `for`+loop avoids allocating or returning an incomplete serialized project array.++**Alternatives considered:**+- Keep the non-throwing service API and add a second MCP-only fetch helper —+  rejected because the original API would still silently collapse storage+  errors and duplicate the scoped query logic.+- Return projects collected before the failure — rejected because clients could+  mistake a partial list for an authoritative response.++## Regression Test++**Test file:** `Transit/TransitTests/MCPGetProjectsTests.swift`+**Test name:** `getProjectsMilestoneFetchFailureReturnsExactToolErrorWithoutPartialProjects`++**What it verifies:** the first scoped milestone fetch succeeds with a valid+empty result and the second fails, proving the exact MCP tool error replaces+(rather than accompanies) a partial successful project list; a separate control+verifies an empty project's omitted `milestones` field and another project's+correctly scoped milestones.++**Run command:** `make test-quick`++Before the fix, the focused `MCPGetProjectsTests` suite fails only the injected+fetch-failure test; the valid-empty/multi-project control passes.++## Affected Files++| File | Change |+|------|--------|+| `Transit/Transit/Services/MilestoneService.swift` | Scoped milestone fetch now throws through injected `ModelFetching`. |+| `Transit/Transit/MCP/MCPToolHandler.swift` | Returns exact fetch error without a partial project response. |+| `Transit/Transit/Views/TaskDetail/TaskEditView+Milestones.swift` | Makes the picker-only empty fallback explicit. |+| `Transit/TransitTests/MCPGetProjectsTests.swift` | Adds deterministic fetch-failure and valid-empty/multi-project coverage. |+| `Transit/TransitTests/MilestoneServiceLookupTests.swift` | Adopts the throwing scoped-fetch contract. |+| `CHANGELOG.md` | Documents the corrected MCP storage-error behavior. |++## Verification++**Automated:**+- [x] Regression fails before the fix.+- [x] Regression passes after the fix (`make test-quick`).+- [x] `make test-quick` passes.+- [x] `make lint` passes.+- [x] `make build` passes for iOS Simulator and macOS.+- [ ] `make test` did not complete within the command runner limit; before+  timeout it reported UI failures in `testClearAll`, `testEditViewPreservesTaskMilestone`,+  and `testDataMaintenanceGoldenPath`, which were not investigated because they+  are outside this ticket's scoped MCP storage-error change.++**Manual verification:** Not needed; deterministic MCP handler tests exercise+the response envelope.++## Prevention++Service methods used by authoritative automation responses must preserve storage+errors. Presentation callers that intentionally degrade to an empty picker must+make that fallback explicit at their call site rather than receiving it from the+service API.++## Related++- T-1711+- T-1675 — scoped milestone-name lookup storage-error semantics+- T-1608 — unscoped MCP milestone fetch storage-error semantics

Things to double-check

Repository UI-test health is independently failing

The exact-head full iOS result bundle is Failed with 1,255 passed and three failures: TransitUITests/testClearAll(), TransitUITests/testEditViewPreservesTaskMilestone(), and DataMaintenanceUITests/testDataMaintenanceGoldenPath(). Each was run against merge base 6dfc0bc and failed there too (exit 65), so this is not a T-1711 regression. It should be tracked separately before treating a repository-wide green iOS suite as a release gate.