Files
my-pi/pi-permission-system/docs/plans/0318-introduce-mcp-target-list.md
T

12 KiB
Raw Blame History

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 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):

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:

// 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) => voidtargets: 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 14, 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 14). 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.