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

187 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 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.