12 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 318 | 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:
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
McpTargetListvalue 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
includesdedup check inside the object so no call site asks the array what it holds. - Rewrite
createMcpPermissionTargets,pushMcpToolPermissionTargets, andaddDerivedMcpServerTargetsso the per-mode branches construct anMcpTargetListand tell it toadd. - Export
McpTargetListand give it focused unit tests that document the invariant directly. - Behavior-preserving:
test/mcp-targets.test.tsstays green; candidate ordering is unchanged.
Non-Goals
- No MCP-naming command methods on the list (e.g.
addQualifiedTool(server, tool),addServerListing(server)).McpTargetListowns 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 singlederiveTargets()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.tsbehavior — 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— exportsparseQualifiedMcpToolNameandcreateMcpPermissionTargets; contains the module-private helpersaddDerivedMcpServerTargetsandpushMcpToolPermissionTargets. All three derivation functions thread apushTarget: (value: string | null) => voidcallback today.src/input-normalizer.ts(line 106) — the sole production consumer:[...createMcpPermissionTargets(input, configuredMcpServerNames), "mcp"]. It spreads the array, so whethertoArray()returns the live array or a copy is invisible to it.test/mcp-targets.test.ts— exercisescreateMcpPermissionTargetsacross 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 matchescreateMcpPermissionTargetsoutput with"mcp"appended; unaffected because the return shape is unchanged.
Constraints from AGENTS.md and skills:
@typescript-eslint/require-awaitis enabled forsrc/— not triggered here (noasyncinvolved).- 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.
McpTargetListis exported and consumed by bothmcp-targets.ts(production) andtest/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 thepushTargetclosure 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):
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:
addabsorbs both the empty/null guard and theincludesdedup — 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:
// 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)andadd("")are no-ops (falsy guard).getNonEmptyStringalready normalizes input, so whitespace never reachesadd. - Duplicate candidates (e.g.
tool: "exa:search"with["exa"]configured):adddedups; ordering follows first-insertion, identical to the oldincludes-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
McpTargetListclass (placed near the top, below the file's leading imports and above or beside the exported functions per the newspaper rule). - Change
addDerivedMcpServerTargetssignature: last parameterpushTarget: (value: string | null) => void→targets: McpTargetList; replacepushTarget(...)calls withtargets.add(...). - Change
pushMcpToolPermissionTargetssignature the same way; replace itspushTarget(...)calls withtargets.add(...)and passtargetsthrough toaddDerivedMcpServerTargets. - Rewrite
createMcpPermissionTargets: replace the localtargetsarray +pushTargetclosure withconst targets = new McpTargetList(); replace everypushTarget(x)withtargets.add(x); replace eachreturn targetswithreturn 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
McpTargetListfrom#src/mcp-targetsalongside the existing imports. - The existing
createMcpPermissionTargetsandparseQualifiedMcpToolNameblocks stay unchanged (regression guard).
docs/architecture/architecture.md:
- Update Finding 4 (line ~785) and Step 5 (line ~818) to reflect that the
pushTargetclosure is resolved by theMcpTargetListvalue 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
- New tests the extraction enables: direct
McpTargetListunit tests that document the ordered-uniqueness invariant in isolation —addignoresnull, ignores"", appends new values, dedups repeats, preserves first-insertion order across a mix, andtoArray()returns a copy that does not mutate the list. These were impossible while the accumulator was a closure-local array. - Tests that become redundant: none are removed.
The existing
does not include duplicate entriestest in thecreateMcpPermissionTargetsblock 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). - Tests that must stay as-is: the entire existing
createMcpPermissionTargetsblock (all six modes + ordering) genuinely exercises the dispatch layer being refactored and is the primary behavior-preservation guard; theparseQualifiedMcpToolNameblock is untouched.
TDD Order
-
red → green → commit —
test/mcp-targets.test.ts, newdescribe("McpTargetList")block. Add the value object and its focused tests in one cycle: write the tests against an exportedMcpTargetList(red — symbol does not exist), add the class tosrc/mcp-targets.ts, run green. Covers:addignores null/empty, appends, dedups, preserves order;toArrayreturns 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, thentest:— but one cycle is cleaner here.) -
green → commit —
src/mcp-targets.ts, rewrite the dispatch. Replace thepushTargetclosure and local array increateMcpPermissionTargetswithnew McpTargetList()/add/toArray(), and repointpushMcpToolPermissionTargets+addDerivedMcpServerTargetsto accept and tell theMcpTargetList. No new test — the existingcreateMcpPermissionTargetsblock is the regression guard and must stay green throughout. Commit:refactor: dispatch MCP targets through McpTargetList. -
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
addchanges insertion semantics. Mitigation:addpreserves the exactincludes-then-pushorder; the existingtool targets appear before mcp_callordering test and all per-modetoContainassertions 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.