Input: Design documents from /specs/075-macos-tcc-connect/
Prerequisites: plan.md, spec.md, research.md, data-model.md, contracts/connect-status.md
Tests: INCLUDED — TDD is required (FR-012 + Constitution V). Each behavior gets a failing test first.
Single Go module. Connect logic in internal/connect/; doctor check in the diagnostics package (pinned in T004); REST mapping in internal/httpapi/connect.go.
- T001 Confirm working on branch
075-macos-tcc-connectand thatgo build ./cmd/mcpproxy+go test ./internal/connect/...are green as a baseline (capture currentGetAllStatusbehavior). - T002 [P] Record the canonical remediation string and bundle IDs (
com.smartmcpproxy.mcpproxy,com.smartmcpproxy.mcpproxy.dev) fromnative/macos/MCPProxy/MCPProxy/Info.plistfor reuse in code + tests.
Purpose: The access-classification primitive every story depends on. MUST complete before US1–US3.
- T003 [P] Add a content-read seam to
internal/connect: aService-heldreadFile func(string) ([]byte, error)(defaultos.ReadFile) and aNewServiceWithReader/test setter, mirroring the existinghomeDiroverride, so tests can inject a permission-denied error. File:internal/connect/connect.go. - T004 Pin the diagnostics package/registration point for the doctor check. Decision: the runtime doctor is
internal/managementservice.Doctor()(diagnostics.go), which buildscontracts.Diagnostics; the macOS App-Data check appends an actionable warning string to itsRuntimeWarnings []string(rendered bymcpproxy doctoras the "⚠️ Runtime Warnings" section and counted inTotalIssues). The staticinternal/diagnosticsregistry is an error-CODE catalog (classification metadata), not a runtime-check registry, so it is NOT used. The build-tagged check lives ininternal/management/tcc_appdata_{darwin,other}.gowith a pure, cross-platform translator intcc_appdata.go.
Goal: GetAllStatus determines installed via os.Stat only and performs zero content reads; per-client content read moves to an on-demand path.
Independent Test: With several client configs present, GetAllStatus reads no file contents (injected reader asserts 0 calls); GetStatus(id) reads exactly one.
- T005 [P] [US1] Write failing test
TestGetAllStatus_NoContentReadsininternal/connect/connect_test.go: inject areadFilethat fails the test if called; create temp client configs viaNewServiceWithHome; assertGetAllStatussetsExistscorrectly and never invokes the reader. - T006 [P] [US1] Write failing test
TestGetStatus_ReadsSingleClientOnDemandininternal/connect/connect_test.go: assertGetStatus(id)triggers exactly one content read for that client and resolvesConnected/AccessState. - T007 [US1] Add
AccessState(json:"access_state") andRemediation(json:"remediation,omitempty") fields toClientStatusininternal/connect/connect.go(additive only; preserve all existing fields per FR-006). - T008 [US1] Refactor
GetAllStatus(internal/connect/connect.go:112) to setExistsviaos.Statand leaveAccessState="unknown"/Connected=falsefor installed clients — REMOVE the eagerfindEntrycall at line ~135. - T009 [US1] Add
func (s *Service) GetStatus(clientID string) (ClientStatus, error)that does the single-client content read via the seam, callsfindEntry, and setsConnected+AccessState=accessible|absent|malformed. File:internal/connect/connect.go. - T010 [US1] Route
findEntry/findEntryJSON/findEntryTOML(andreadOrCreate*) throughs.readFileinstead of callingos.ReadFiledirectly, so the seam covers all content reads. File:internal/connect/connect.go. - T011 [US1] Update
GetConnectedCount/GetConnectedIDs(connect.go:87/100) which currently rely onGetAllStatus().Connected: re-implement them to use per-client on-demandGetStatus(they legitimately need the connected truth for the wizard predicate) — document that this is the one internal caller allowed to content-read, and it does so lazily per client. - T012 [US1] Run
go test ./internal/connect/ -run 'Status' -race→ green. Verify T005/T006 pass.
Checkpoint: Overall status is content-read-free; connected detection is on-demand. SC-001/SC-002 satisfied.
Goal: Classify access into accessible/absent/denied/malformed and surface a distinct, actionable "blocked by macOS privacy" outcome on reads AND writes.
Independent Test: Injected fs.ErrPermission on a config access yields AccessState=denied + remediation; absent → absent; bad JSON → malformed; each distinct.
- T013 [P] [US2] Write failing tests in new
internal/connect/access_test.goforclassifyAccess(err): nil→accessible,fs.ErrNotExist→absent,fs.ErrPermission(wrapsyscall.EPERMandEACCES)→denied, parse-error→malformed. Use&fs.PathError{Err: syscall.EPERM}. - T014 [P] [US2] Write failing test
TestGetStatus_DeniedSurfacesRemediationininternal/connect/connect_test.go: inject reader returning EPERM; assertGetStatusreturnsAccessState=denied, non-emptyRemediationcontainingtccutil reset SystemPolicyAppDataand the bundle id, and thatConnectedstays false (not reported as plain "not connected"). - T015 [P] [US2] Write failing test
TestConnectDenied_ReturnsAccessErrorininternal/connect/connect_test.go: inject EPERM on the connect read/write path; assert a typed*AccessError(errors.As) carrying remediation is returned (distinct from unknown-client / already-exists errors). - T016 [US2] Implement
internal/connect/access.go:AccessOutcomeenum,classifyAccess(err) AccessOutcome(viaerrors.Is(err, fs.ErrPermission)/fs.ErrNotExist),AccessErrortype (Client/Path/Outcome/Remediation,Error()+Unwrap), andremediationText(client)building the canonical message from data-model.md. - T017 [US2] Wire
classifyAccessintoGetStatus(T009) so denied/malformed/absent setAccessState+Remediation. - T018 [US2] Wire
classifyAccessintoConnect/Disconnect(connect.go) andbackupFile(internal/connect/backup.go): ondenied, return an*AccessErrorwith remediation; preserve existing error semantics otherwise. - T019 [US2] Run
go test ./internal/connect/ -run 'Access|Denied|Connect' -race→ green (T013/T014/T015).
Checkpoint: All four access outcomes are distinct and surfaced; denials are actionable. SC-003/SC-004 satisfied.
Goal: macOS-only diagnostics check that detects a persisted App-Data denial affecting Connect and reports remediation; no-op elsewhere.
Independent Test: On darwin with an injected denial → warn + remediation; darwin with access OK → pass; non-darwin → check absent/no-op.
- T020 [P] [US3] Write failing test
internal/connect/...or diagnostics test for aServicehelperDetectAppDataDenial() (denied bool, remediation string): inject reader returning EPERM on an existing (stat-true) client → denied=true + remediation; reader OK → denied=false; no installed clients → denied=false (no false positive). - T021 [P] [US3] Write failing test
tcc_appdata_test.go(build-tagged darwin) asserting the diagnostics check returns warn whenDetectAppDataDenialreports denied, and pass otherwise; plus a!darwintest asserting the check is not registered / is a no-op. - T022 [US3] Implement
Service.DetectAppDataDenial()ininternal/connect/access.go: iterate clients, for the first thatos.Stat-exists, attempt a content read via the seam; ifclassifyAccess==denied return (true, remediation); else (false, ""). - T023 [US3] Implement
internal/<diagnostics-pkg>/tcc_appdata_darwin.go(//go:build darwin): a check that callsDetectAppDataDenialand emits warn+remediation; register it in the registry pinned in T004. Addtcc_appdata_other.go(//go:build !darwin) no-op stub. - T024 [US3] Run
go test ./internal/connect/... ./internal/<diagnostics-pkg>/... -run 'AppData|TCC|Denial' -race→ green. Build & smoke./mcpproxy doctoron darwin.
Checkpoint: Doctor surfaces persisted denials with a one-command fix. SC-005 satisfied.
- T025 [P] Map new
ClientStatusfields (access_state,remediation) into the REST response ininternal/httpapi/connect.go; ensure GET/connectpayload is additive only. Confirm/append a per-client GET/connect/{client}on-demand status route per contracts (or document that connect/disconnect carry the denied error). DONE: GET/connectalready serializes the additive fields (unknown); addedGET /connect/{client}on-demand route (handleGetConnectClientStatus→GetStatus, 404 unknown, denied reported in-band) and mapped denied connect/disconnect*AccessError→403with remediation. - T026 [P] Run the existing Connect REST contract/integration tests and
./scripts/test-api-e2e.sh; confirm no regression (SC-006). DONE:test-api-e2e.sh65/65 PASS; new REST tests inconnect_test.gogreen. - T027 [P] Run CI linter locally:
/opt/homebrew/bin/golangci-lint run --config .github/.golangci.yml internal/connect/... internal/httpapi/...→ 0 issues (Constitution V; CI uses v2). DONE: 0 issues. (diagnostics-pkg doctor check is separate issue MCP-2831, not yet on main.) - T028 [P] Docs: add a short macOS "App Data privacy & Connect" note (cause +
tccutil resetremediation) to the Connect/troubleshooting docs and update CLAUDE.md REST-payload notes for the new fields (Constitution VI). DONE:docs/api/rest-api.mdConnect section + CLAUDE.md REST note. - T029 Full verification:
go build ./cmd/mcpproxy && go build -tags server ./cmd/mcpproxy && go test -race ./internal/connect/... ./internal/httpapi/.... DONE: both editions build; race tests green;make swaggerregeneratedoas/. - T030 [P] (Optional, separable) Frontend: render the
unknown/deniedtri-state + remediation banner in the Connect view (frontend/src/), verified via the Playwright sweep in CLAUDE.md. Not required for backend MVP.
- Setup (T001–T002) → Foundational (T003–T004) → user stories.
- US1 (P1, T005–T012): depends only on Foundational. This is the MVP — kills the prompt storm.
- US2 (P2, T013–T019): depends on US1 (
GetStatus+ seam). Adds classification/surfacing. - US3 (P3, T020–T024): depends on US2 (
classifyAccess,DetectAppDataDenial). - Polish (T025–T030): after the stories it touches; T025/T026 need US1+US2.
Setup → Foundational → US1 → US2 → US3 → Polish
│ │
└─MVP──┘ (US1 alone delivers SC-001/SC-002)
- T002 ∥ within setup.
- All test-authoring tasks marked [P] within a phase (T005/T006; T013/T014/T015; T020/T021) — different test funcs, write first, watch fail.
- Polish T025/T026/T027/T028 ∥ (different files), then T029 gate.
- MVP = US1 (T001–T012): stat-only status removes the prompt storm — ship-able alone, addresses the root cause for issue #696's sibling TCC report.
- US2 makes denials diagnosable; US3 adds the doctor convenience.
- Each phase ends green under
-race+ linter before the next. Frontend (T030) ships separately.
- Issue link:
Related #696(per spec commit conventions). Do NOT use auto-closing keywords; no Claude co-author trailer. - Out of scope (do not touch): Docker well-known probe (
shellwrap/secureenv), entitlements, signing/notarization.