--- issue: 318 issue_title: "Introduce an McpTargetList value object in mcp-targets.ts" --- # Introduce an `McpTargetList` value object in `mcp-targets.ts` ## Problem Statement `createMcpPermissionTargets` accumulates permission-lookup candidates through a `pushTarget` closure over a mutable local array, deduping by hand: ```typescript const targets: string[] = []; const pushTarget = (value: string | null) => { if (!value) return; if (!targets.includes(value)) targets.push(value); }; ``` The core smell is the `includes` check: every push site asks the array what it already contains, then acts on it — the ordered-uniqueness invariant lives in the caller, not in the array. That is a Tell-Don't-Ask violation, repeated implicitly at every push across the per-mode branches (tool / connect / describe / search / list / status). This is the one remaining "mutable closure state with no owner" in the package after the Phase 3 forwarding work (Track C, Step 5 of the architecture roadmap). The fix gives the accumulator an owner: a small `McpTargetList` value object whose `add` swallows the empty/null guard and the dedup, so the per-mode dispatch reads as a sequence of tells. ## Goals - Add an `McpTargetList` value object that owns the ordered-uniqueness invariant: `add(value)` ignores empty/null and appends only when the value is not already present; `toArray()` returns the ordered result. - Move the `includes` dedup check inside the object so no call site asks the array what it holds. - Rewrite `createMcpPermissionTargets`, `pushMcpToolPermissionTargets`, and `addDerivedMcpServerTargets` so the per-mode branches construct an `McpTargetList` and tell it to `add`. - Export `McpTargetList` and give it focused unit tests that document the invariant directly. - Behavior-preserving: `test/mcp-targets.test.ts` stays green; candidate ordering is unchanged. ## Non-Goals - No MCP-naming command methods on the list (e.g. `addQualifiedTool(server, tool)`, `addServerListing(server)`). `McpTargetList` owns ordering + uniqueness only; the `${server}_${tool}` / `${server}:${tool}` / `mcp_server_${server}` spelling is a separate responsibility that stays in the pure dispatch functions that tell the list. - No `McpInvocation` / `deriveTargets()` class. Modeling the input as an object with a single `deriveTargets()` method plus a constructor is a one-shot transform wearing a class costume — no retained state across calls, no polymorphic seam. The dispatch stays a function. - No change to `parseQualifiedMcpToolName` (its signature, behavior, and tests are untouched). - No change to `src/input-normalizer.ts` behavior — it spreads the returned array and appends `"mcp"`; the returned shape (`string[]`) is unchanged. - No change to the candidate ordering, the set of candidates produced for any input, or any user-visible permission decision. ## Background Relevant existing modules: - `src/mcp-targets.ts` — exports `parseQualifiedMcpToolName` and `createMcpPermissionTargets`; contains the module-private helpers `addDerivedMcpServerTargets` and `pushMcpToolPermissionTargets`. All three derivation functions thread a `pushTarget: (value: string | null) => void` callback today. - `src/input-normalizer.ts` (line 106) — the sole production consumer: `[...createMcpPermissionTargets(input, configuredMcpServerNames), "mcp"]`. It spreads the array, so whether `toArray()` returns the live array or a copy is invisible to it. - `test/mcp-targets.test.ts` — exercises `createMcpPermissionTargets` across all six modes plus a dedup assertion (`does not include duplicate entries`) and an ordering assertion (`tool targets appear before mcp_call`). - `test/input-normalizer.test.ts` (line 175) — asserts the normalizer output matches `createMcpPermissionTargets` output with `"mcp"` appended; unaffected because the return shape is unchanged. Constraints from AGENTS.md and skills: - `@typescript-eslint/require-await` is enabled for `src/` — not triggered here (no `async` involved). - Within the package, tests import via the `#src/` alias (`#src/mcp-targets`), not relative paths. - Code organization (newspaper / stepdown): exported API near the top, helpers below their callers. - Do not add speculative re-exports; fallow flags them as dead code. `McpTargetList` is exported and consumed by both `mcp-targets.ts` (production) and `test/mcp-targets.test.ts`, so it has real consumers — no dead-export risk. - The architecture doc (`docs/architecture/architecture.md`) records this as Finding 4 / Step 5 and references the `pushTarget` closure by name; it needs an update once the closure is gone. ## Design Overview ### The value object `McpTargetList` owns a private array and exposes exactly two methods — `add` (a command that tells) and `toArray` (a query that reads the ordered result): ```typescript export class McpTargetList { private readonly targets: string[] = []; add(value: string | null): void { if (!value) { return; } if (!this.targets.includes(value)) { this.targets.push(value); } } toArray(): string[] { return [...this.targets]; } } ``` Design notes: - `add` absorbs both the empty/null guard and the `includes` dedup — the two responsibilities that were inlined at every call site. - `toArray()` returns a defensive copy (`[...this.targets]`). The current code returns the live array, but the sole consumer spreads it, so the copy is behavior-preserving and prevents external mutation of the list's internal state. - The class is intentionally generic — it knows nothing about MCP naming. It is a thin ordered-set accumulator; the MCP spelling stays in the dispatch functions. ### Dispatch tells the list The two helpers stop taking a `pushTarget` callback and instead take the `McpTargetList` directly, calling `targets.add(...)`. This is the per-mode dispatch telling the list rather than asking an array: ```typescript // createMcpPermissionTargets, tool branch (sketch) const targets = new McpTargetList(); if (tool) { pushMcpToolPermissionTargets(tool, server, configuredServerNames, targets); targets.add("mcp_call"); return targets.toArray(); } ``` `pushMcpToolPermissionTargets` and `addDerivedMcpServerTargets` change their last parameter from `pushTarget: (value: string | null) => void` to `targets: McpTargetList` and replace each `pushTarget(x)` with `targets.add(x)`. No control flow, ordering, or candidate set changes — only the accumulation mechanism. ### Extraction interaction audit The new module does not import anything new — `McpTargetList` is self-contained (no upstream dependencies, no SDK types). The helpers already received the accumulation behavior as a callback parameter (`pushTarget`); swapping the callback for an injected object that owns the same behavior is a direct DIP-friendly substitution with no reverse-search, output-argument, or LoD concerns. Each branch still returns `targets.toArray()` instead of the bare `targets` array — the function returns a value; the list owns the invariant. ### Edge cases (all already covered by behavior) - Empty/null/whitespace values: `add(null)` and `add("")` are no-ops (falsy guard). `getNonEmptyString` already normalizes input, so whitespace never reaches `add`. - Duplicate candidates (e.g. `tool: "exa:search"` with `["exa"]` configured): `add` dedups; ordering follows first-insertion, identical to the old `includes`-then-`push`. - Insertion order is the candidate priority (most-specific first); `toArray()` preserves it. ## Module-Level Changes `src/mcp-targets.ts`: - Add and export the `McpTargetList` class (placed near the top, below the file's leading imports and above or beside the exported functions per the newspaper rule). - Change `addDerivedMcpServerTargets` signature: last parameter `pushTarget: (value: string | null) => void` → `targets: McpTargetList`; replace `pushTarget(...)` calls with `targets.add(...)`. - Change `pushMcpToolPermissionTargets` signature the same way; replace its `pushTarget(...)` calls with `targets.add(...)` and pass `targets` through to `addDerivedMcpServerTargets`. - Rewrite `createMcpPermissionTargets`: replace the local `targets` array + `pushTarget` closure with `const targets = new McpTargetList()`; replace every `pushTarget(x)` with `targets.add(x)`; replace each `return targets` with `return targets.toArray()`. `test/mcp-targets.test.ts`: - Add a `describe("McpTargetList")` block with focused unit tests for the invariant (see TDD order Step 1). - Import `McpTargetList` from `#src/mcp-targets` alongside the existing imports. - The existing `createMcpPermissionTargets` and `parseQualifiedMcpToolName` blocks stay unchanged (regression guard). `docs/architecture/architecture.md`: - Update Finding 4 (line ~785) and Step 5 (line ~818) to reflect that the `pushTarget` closure is resolved by the `McpTargetList` value object (mark the step done in the style of Steps 1–4, which carry a ✅ and an Outcome). No other `src/` or `test/` file imports the changed symbols; the package skill does not reference `mcp-targets.ts` internals by name (verified by grep), so no skill update is required. ## Test Impact Analysis 1. New tests the extraction enables: direct `McpTargetList` unit tests that document the ordered-uniqueness invariant in isolation — `add` ignores `null`, ignores `""`, appends new values, dedups repeats, preserves first-insertion order across a mix, and `toArray()` returns a copy that does not mutate the list. These were impossible while the accumulator was a closure-local array. 2. Tests that become redundant: none are removed. The existing `does not include duplicate entries` test in the `createMcpPermissionTargets` block now overlaps with the direct dedup test, but it stays as an integration-level regression guard (it verifies dedup through the real dispatch, not just the list in isolation). 3. Tests that must stay as-is: the entire existing `createMcpPermissionTargets` block (all six modes + ordering) genuinely exercises the dispatch layer being refactored and is the primary behavior-preservation guard; the `parseQualifiedMcpToolName` block is untouched. ## TDD Order 1. red → green → commit — `test/mcp-targets.test.ts`, new `describe("McpTargetList")` block. Add the value object and its focused tests in one cycle: write the tests against an exported `McpTargetList` (red — symbol does not exist), add the class to `src/mcp-targets.ts`, run green. Covers: `add` ignores null/empty, appends, dedups, preserves order; `toArray` returns an independent copy. Commit: `test: add McpTargetList value object with ordered-uniqueness tests`. (Combined test+impl because the class is the unit under test; suggested split — if preferred, `feat:` the class first, then `test:` — but one cycle is cleaner here.) 2. green → commit — `src/mcp-targets.ts`, rewrite the dispatch. Replace the `pushTarget` closure and local array in `createMcpPermissionTargets` with `new McpTargetList()` / `add` / `toArray()`, and repoint `pushMcpToolPermissionTargets` + `addDerivedMcpServerTargets` to accept and tell the `McpTargetList`. No new test — the existing `createMcpPermissionTargets` block is the regression guard and must stay green throughout. Commit: `refactor: dispatch MCP targets through McpTargetList`. 3. docs → commit — `docs/architecture/architecture.md`. Mark roadmap Step 5 done and update Finding 4 to note the closure is replaced by the value object (matching the ✅/Outcome style of Steps 1–4). Commit: `docs: record McpTargetList resolves the pushTarget closure (#318)`. This is a behavior-preserving refactor, so there is no `feat!:` and no breaking change. ## Risks and Mitigations - Risk: ordering regression if `add` changes insertion semantics. Mitigation: `add` preserves the exact `includes`-then-`push` order; the existing `tool targets appear before mcp_call` ordering test and all per-mode `toContain` assertions guard it. - Risk: a caller relying on `toArray()` returning the live array and mutating it. Mitigation: the sole consumer (`input-normalizer.ts`) spreads the result; the defensive copy is strictly safer and behavior-identical. - Risk: scope creep into MCP-naming command methods on the list. Mitigation: explicit Non-Goal; the list stays generic and the spelling stays in the dispatch functions. ## Open Questions None. The issue's "Proposed change" and "Non-goals" sections fully specify the design; the only decision (export + directly test `McpTargetList`) was confirmed with the user before writing this plan.