mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 16:45:22 +00:00
187 lines
12 KiB
Markdown
187 lines
12 KiB
Markdown
---
|
||
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.
|