From 6d7a427864acc662231a4942de21862106330391 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 23 Feb 2026 15:34:35 -0500 Subject: [PATCH 1/4] feat: assign unassigned targets to gateways and preserve targets on removal --- src/cli/operations/mcp/create-mcp.ts | 37 ++++- src/cli/operations/remove/remove-gateway.ts | 8 +- src/cli/tui/hooks/useCreateMcp.ts | 20 +++ src/cli/tui/screens/mcp/AddGatewayFlow.tsx | 5 +- src/cli/tui/screens/mcp/AddGatewayScreen.tsx | 60 ++++++-- src/cli/tui/screens/mcp/types.ts | 5 +- .../tui/screens/mcp/useAddGatewayWizard.ts | 38 ++++- .../tui/screens/schema/McpGuidedEditor.tsx | 135 +++++++++++++++--- 8 files changed, 272 insertions(+), 36 deletions(-) diff --git a/src/cli/operations/mcp/create-mcp.ts b/src/cli/operations/mcp/create-mcp.ts index 4e9b7d56..d6e36a1f 100644 --- a/src/cli/operations/mcp/create-mcp.ts +++ b/src/cli/operations/mcp/create-mcp.ts @@ -80,6 +80,22 @@ function buildAuthorizerConfiguration(config: AddGatewayConfig): AgentCoreGatewa }; } +/** + * Get list of unassigned targets from MCP spec. + */ +export async function getUnassignedTargets(): Promise { + try { + const configIO = new ConfigIO(); + if (!configIO.configExists('mcp')) { + return []; + } + const mcpSpec = await configIO.readMcpSpec(); + return mcpSpec.unassignedTargets ?? []; + } catch { + return []; + } +} + /** * Get list of existing gateway names from project spec. */ @@ -160,15 +176,34 @@ export async function createGatewayFromWizard(config: AddGatewayConfig): Promise throw new Error(`Gateway "${config.name}" already exists.`); } + // Collect selected unassigned targets + const selectedTargets: AgentCoreGatewayTarget[] = []; + if (config.selectedTargets && config.selectedTargets.length > 0) { + const unassignedTargets = mcpSpec.unassignedTargets ?? []; + for (const targetName of config.selectedTargets) { + const target = unassignedTargets.find(t => t.name === targetName); + if (target) { + selectedTargets.push(target); + } + } + } + const gateway: AgentCoreGateway = { name: config.name, description: config.description, - targets: [], + targets: selectedTargets, authorizerType: config.authorizerType, authorizerConfiguration: buildAuthorizerConfiguration(config), }; mcpSpec.agentCoreGateways.push(gateway); + + // Remove selected targets from unassigned targets + if (config.selectedTargets && config.selectedTargets.length > 0) { + const selected = config.selectedTargets; + mcpSpec.unassignedTargets = (mcpSpec.unassignedTargets ?? []).filter(t => !selected.includes(t.name)); + } + await configIO.writeMcpSpec(mcpSpec); return { name: config.name }; diff --git a/src/cli/operations/remove/remove-gateway.ts b/src/cli/operations/remove/remove-gateway.ts index 201f7f12..32780e5e 100644 --- a/src/cli/operations/remove/remove-gateway.ts +++ b/src/cli/operations/remove/remove-gateway.ts @@ -34,7 +34,7 @@ export async function previewRemoveGateway(gatewayName: string): Promise 0) { - summary.push(`Note: ${gateway.targets.length} target(s) behind this gateway will become orphaned`); + summary.push(`Note: ${gateway.targets.length} target(s) will become unassigned`); } // Compute schema changes @@ -52,9 +52,15 @@ export async function previewRemoveGateway(gatewayName: string): Promise g.name === gatewayName); + const targetsToPreserve = gatewayToRemove?.targets ?? []; + return { ...mcpSpec, agentCoreGateways: mcpSpec.agentCoreGateways.filter(g => g.name !== gatewayName), + ...(targetsToPreserve.length > 0 || mcpSpec.unassignedTargets + ? { unassignedTargets: [...(mcpSpec.unassignedTargets ?? []), ...targetsToPreserve] } + : {}), }; } diff --git a/src/cli/tui/hooks/useCreateMcp.ts b/src/cli/tui/hooks/useCreateMcp.ts index 7d0971eb..2b2b4857 100644 --- a/src/cli/tui/hooks/useCreateMcp.ts +++ b/src/cli/tui/hooks/useCreateMcp.ts @@ -5,6 +5,7 @@ import { getAvailableAgents, getExistingGateways, getExistingToolNames, + getUnassignedTargets, } from '../../operations/mcp/create-mcp'; import type { AddGatewayConfig, AddGatewayTargetConfig } from '../screens/mcp/types'; import { useCallback, useEffect, useState } from 'react'; @@ -117,3 +118,22 @@ export function useExistingToolNames() { return { toolNames, refresh }; } + +export function useUnassignedTargets() { + const [targets, setTargets] = useState([]); + + useEffect(() => { + async function load() { + const result = await getUnassignedTargets(); + setTargets(result.map(t => t.name)); + } + void load(); + }, []); + + const refresh = useCallback(async () => { + const result = await getUnassignedTargets(); + setTargets(result.map(t => t.name)); + }, []); + + return { targets, refresh }; +} diff --git a/src/cli/tui/screens/mcp/AddGatewayFlow.tsx b/src/cli/tui/screens/mcp/AddGatewayFlow.tsx index 36191b84..105b4f8c 100644 --- a/src/cli/tui/screens/mcp/AddGatewayFlow.tsx +++ b/src/cli/tui/screens/mcp/AddGatewayFlow.tsx @@ -3,7 +3,7 @@ import type { SelectableItem } from '../../components'; import { HELP_TEXT } from '../../constants'; import { useListNavigation } from '../../hooks'; import { useAgents, useAttachGateway, useGateways } from '../../hooks/useAttach'; -import { useCreateGateway, useExistingGateways } from '../../hooks/useCreateMcp'; +import { useCreateGateway, useExistingGateways, useUnassignedTargets } from '../../hooks/useCreateMcp'; import { AddSuccessScreen } from '../add/AddSuccessScreen'; import { AddGatewayScreen } from './AddGatewayScreen'; import type { AddGatewayConfig } from './types'; @@ -55,6 +55,7 @@ export function AddGatewayFlow({ }: AddGatewayFlowProps) { const { createGateway, reset: resetCreate } = useCreateGateway(); const { gateways: existingGateways, refresh: refreshGateways } = useExistingGateways(); + const { targets: unassignedTargets } = useUnassignedTargets(); const [flow, setFlow] = useState({ name: 'mode-select' }); // Bind flow hooks @@ -157,6 +158,7 @@ export function AddGatewayFlow({ @@ -183,6 +185,7 @@ export function AddGatewayFlow({ setFlow({ name: 'mode-select' })} /> diff --git a/src/cli/tui/screens/mcp/AddGatewayScreen.tsx b/src/cli/tui/screens/mcp/AddGatewayScreen.tsx index 0a6bba97..1e2db8af 100644 --- a/src/cli/tui/screens/mcp/AddGatewayScreen.tsx +++ b/src/cli/tui/screens/mcp/AddGatewayScreen.tsx @@ -24,10 +24,17 @@ interface AddGatewayScreenProps { onExit: () => void; existingGateways: string[]; availableAgents: string[]; + unassignedTargets: string[]; } -export function AddGatewayScreen({ onComplete, onExit, existingGateways, availableAgents }: AddGatewayScreenProps) { - const wizard = useAddGatewayWizard(); +export function AddGatewayScreen({ + onComplete, + onExit, + existingGateways, + availableAgents, + unassignedTargets, +}: AddGatewayScreenProps) { + const wizard = useAddGatewayWizard(unassignedTargets.length); // JWT config sub-step tracking (0 = discoveryUrl, 1 = audience, 2 = clients) const [jwtSubStep, setJwtSubStep] = useState(0); @@ -39,6 +46,11 @@ export function AddGatewayScreen({ onComplete, onExit, existingGateways, availab [availableAgents] ); + const unassignedTargetItems: SelectableItem[] = useMemo( + () => unassignedTargets.map(name => ({ id: name, title: name })), + [unassignedTargets] + ); + const authorizerItems: SelectableItem[] = useMemo( () => AUTHORIZER_TYPE_OPTIONS.map(o => ({ id: o.id, title: o.title, description: o.description })), [] @@ -48,6 +60,7 @@ export function AddGatewayScreen({ onComplete, onExit, existingGateways, availab const isAuthorizerStep = wizard.step === 'authorizer'; const isJwtConfigStep = wizard.step === 'jwt-config'; const isAgentsStep = wizard.step === 'agents'; + const isIncludeTargetsStep = wizard.step === 'include-targets'; const isConfirmStep = wizard.step === 'confirm'; const authorizerNav = useListNavigation({ @@ -66,6 +79,15 @@ export function AddGatewayScreen({ onComplete, onExit, existingGateways, availab requireSelection: false, }); + const targetsNav = useMultiSelectNavigation({ + items: unassignedTargetItems, + getId: item => item.id, + onConfirm: ids => wizard.setSelectedTargets(ids), + onExit: () => wizard.goBack(), + isActive: isIncludeTargetsStep, + requireSelection: false, + }); + useListNavigation({ items: [{ id: 'confirm', title: 'Confirm' }], onSelect: () => onComplete(wizard.config), @@ -113,13 +135,14 @@ export function AddGatewayScreen({ onComplete, onExit, existingGateways, availab } }; - const helpText = isAgentsStep - ? 'Space toggle · Enter confirm · Esc back' - : isConfirmStep - ? HELP_TEXT.CONFIRM_CANCEL - : isAuthorizerStep - ? HELP_TEXT.NAVIGATE_SELECT - : HELP_TEXT.TEXT_INPUT; + const helpText = + isAgentsStep || isIncludeTargetsStep + ? 'Space toggle · Enter confirm · Esc back' + : isConfirmStep + ? HELP_TEXT.CONFIRM_CANCEL + : isAuthorizerStep + ? HELP_TEXT.NAVIGATE_SELECT + : HELP_TEXT.TEXT_INPUT; const headerContent = ; @@ -178,6 +201,18 @@ export function AddGatewayScreen({ onComplete, onExit, existingGateways, availab ))} + {isIncludeTargetsStep && + (unassignedTargetItems.length > 0 ? ( + + ) : ( + No unassigned targets available. Press Enter to continue. + ))} + {isConfirmStep && ( 0 ? wizard.config.agents.join(', ') : '(none)' }, + { + label: 'Targets', + value: + wizard.config.selectedTargets && wizard.config.selectedTargets.length > 0 + ? wizard.config.selectedTargets.join(', ') + : '(none)', + }, ]} /> )} diff --git a/src/cli/tui/screens/mcp/types.ts b/src/cli/tui/screens/mcp/types.ts index 8a5ffeaa..e6151154 100644 --- a/src/cli/tui/screens/mcp/types.ts +++ b/src/cli/tui/screens/mcp/types.ts @@ -4,7 +4,7 @@ import type { GatewayAuthorizerType, NodeRuntime, PythonRuntime, ToolDefinition // Gateway Flow Types // ───────────────────────────────────────────────────────────────────────────── -export type AddGatewayStep = 'name' | 'authorizer' | 'jwt-config' | 'agents' | 'confirm'; +export type AddGatewayStep = 'name' | 'authorizer' | 'jwt-config' | 'agents' | 'include-targets' | 'confirm'; export interface AddGatewayConfig { name: string; @@ -19,6 +19,8 @@ export interface AddGatewayConfig { allowedAudience: string[]; allowedClients: string[]; }; + /** Selected unassigned targets to include in this gateway */ + selectedTargets?: string[]; } export const GATEWAY_STEP_LABELS: Record = { @@ -26,6 +28,7 @@ export const GATEWAY_STEP_LABELS: Record = { authorizer: 'Authorizer', 'jwt-config': 'JWT Config', agents: 'Agents', + 'include-targets': 'Include Targets', confirm: 'Confirm', }; diff --git a/src/cli/tui/screens/mcp/useAddGatewayWizard.ts b/src/cli/tui/screens/mcp/useAddGatewayWizard.ts index 136c8899..7600b49c 100644 --- a/src/cli/tui/screens/mcp/useAddGatewayWizard.ts +++ b/src/cli/tui/screens/mcp/useAddGatewayWizard.ts @@ -16,20 +16,32 @@ function getDefaultConfig(): AddGatewayConfig { agents: [], authorizerType: 'NONE', jwtConfig: undefined, + selectedTargets: [], }; } -export function useAddGatewayWizard() { +export function useAddGatewayWizard(unassignedTargetsCount = 0) { const [config, setConfig] = useState(getDefaultConfig); const [step, setStep] = useState('name'); - // Dynamic steps based on authorizer type + // Dynamic steps based on authorizer type and unassigned targets const steps = useMemo(() => { + const baseSteps: AddGatewayStep[] = ['name', 'authorizer']; + if (config.authorizerType === 'CUSTOM_JWT') { - return ['name', 'authorizer', 'jwt-config', 'agents', 'confirm']; + baseSteps.push('jwt-config'); + } + + baseSteps.push('agents'); + + if (unassignedTargetsCount > 0) { + baseSteps.push('include-targets'); } - return ['name', 'authorizer', 'agents', 'confirm']; - }, [config.authorizerType]); + + baseSteps.push('confirm'); + + return baseSteps; + }, [config.authorizerType, unassignedTargetsCount]); const currentIndex = steps.indexOf(step); @@ -69,10 +81,21 @@ export function useAddGatewayWizard() { [] ); - const setAgents = useCallback((agents: string[]) => { + const setAgents = useCallback( + (agents: string[]) => { + setConfig(c => ({ + ...c, + agents, + })); + setStep(unassignedTargetsCount > 0 ? 'include-targets' : 'confirm'); + }, + [unassignedTargetsCount] + ); + + const setSelectedTargets = useCallback((selectedTargets: string[]) => { setConfig(c => ({ ...c, - agents, + selectedTargets, })); setStep('confirm'); }, []); @@ -92,6 +115,7 @@ export function useAddGatewayWizard() { setAuthorizerType, setJwtConfig, setAgents, + setSelectedTargets, reset, }; } diff --git a/src/cli/tui/screens/schema/McpGuidedEditor.tsx b/src/cli/tui/screens/schema/McpGuidedEditor.tsx index d0b0af33..30760bd0 100644 --- a/src/cli/tui/screens/schema/McpGuidedEditor.tsx +++ b/src/cli/tui/screens/schema/McpGuidedEditor.tsx @@ -90,7 +90,7 @@ function McpEditorBody(props: { onRequestAdd?: () => void; }) { const [gateways, setGateways] = useState(props.initialSpec.agentCoreGateways); - const [unassignedTargets, _setUnassignedTargets] = useState( + const [unassignedTargets, setUnassignedTargets] = useState( props.initialSpec.unassignedTargets ?? [] ); // Only gateways view mode @@ -105,6 +105,9 @@ function McpEditorBody(props: { // Target editing state const [selectedTargetIndex, setSelectedTargetIndex] = useState(0); const [editingTargetFieldId, setEditingTargetFieldId] = useState(null); + // Unassigned target assignment state + const [selectedUnassignedIndex, setSelectedUnassignedIndex] = useState(0); + const [assigningTarget, setAssigningTarget] = useState(false); // Define editable fields for the current item const currentGateway = gateways[selectedIndex]; @@ -139,6 +142,28 @@ function McpEditorBody(props: { } } + function assignTargetToGateway(targetIndex: number, gatewayIndex: number) { + const target = unassignedTargets[targetIndex]; + if (!target) return; + + // Remove from unassigned targets + const newUnassignedTargets = unassignedTargets.filter((_, idx) => idx !== targetIndex); + setUnassignedTargets(newUnassignedTargets); + + // Add to selected gateway + const newGateways = gateways.map((gateway, idx) => { + if (idx === gatewayIndex) { + return { + ...gateway, + targets: [...gateway.targets, target], + }; + } + return gateway; + }); + setGateways(newGateways); + setDirty(true); + } + useInput((input, key) => { // Handle confirm-exit screen if (screenMode === 'confirm-exit') { @@ -219,6 +244,10 @@ function McpEditorBody(props: { // List mode keys if (key.escape) { + if (assigningTarget) { + setAssigningTarget(false); + return; + } if (expandedIndex !== null) { setExpandedIndex(null); return; @@ -231,6 +260,51 @@ function McpEditorBody(props: { return; } + // Handle unassigned target assignment mode + if (assigningTarget) { + if (key.upArrow && gateways.length > 0) { + setSelectedIndex(idx => Math.max(0, idx - 1)); + return; + } + if (key.downArrow && gateways.length > 0) { + setSelectedIndex(idx => Math.min(gateways.length - 1, idx + 1)); + return; + } + if (key.return && gateways.length > 0) { + assignTargetToGateway(selectedUnassignedIndex, selectedIndex); + setAssigningTarget(false); + setSelectedUnassignedIndex(0); + return; + } + return; + } + + // Handle unassigned targets navigation (when not in assignment mode) + if (unassignedTargets.length > 0 && !assigningTarget) { + // U key to focus unassigned targets + if (input.toLowerCase() === 'u') { + setSelectedUnassignedIndex(0); + return; + } + + // When focused on unassigned targets, use left/right arrows to navigate + if (key.leftArrow && unassignedTargets.length > 0) { + setSelectedUnassignedIndex(idx => Math.max(0, idx - 1)); + return; + } + if (key.rightArrow && unassignedTargets.length > 0) { + setSelectedUnassignedIndex(idx => Math.min(unassignedTargets.length - 1, idx + 1)); + return; + } + + // Enter to start assignment when focused on unassigned target + if (key.return && selectedUnassignedIndex < unassignedTargets.length) { + setAssigningTarget(true); + setSelectedIndex(0); + return; + } + } + // A to add (works in both views) if (input.toLowerCase() === 'a' && props.onRequestAdd) { props.onRequestAdd(); @@ -612,21 +686,50 @@ function McpEditorBody(props: { - {unassignedTargets.map((target, idx) => { - const targetName = target.name ?? `Target ${idx + 1}`; - const targetType = target.targetType; - const endpoint = target.endpoint; - const displayInfo = endpoint ?? target.compute?.host ?? targetType; - return ( - - - {targetName} - - ({targetType} · {displayInfo}) - - - ); - })} + {assigningTarget && ( + + + Assign "{unassignedTargets[selectedUnassignedIndex]?.name}" to gateway: + + + )} + {assigningTarget + ? // Show gateway selection for assignment + gateways.map((gateway, idx) => ( + + {idx === selectedIndex ? '>' : ' '} + {gateway.name} + + )) + : // Show unassigned targets + unassignedTargets.map((target, idx) => { + const targetName = target.name ?? `Target ${idx + 1}`; + const targetType = target.targetType; + const endpoint = target.endpoint; + const displayInfo = endpoint ?? target.compute?.host ?? targetType; + const isSelected = idx === selectedUnassignedIndex; + return ( + + + + {isSelected ? '>' : ' '} {targetName} + + + ({targetType} · {displayInfo}) + + + ); + })} + {!assigningTarget && unassignedTargets.length > 0 && ( + + U select · ←→ navigate · Enter assign + + )} + {assigningTarget && ( + + ↑↓ select gateway · Enter confirm · Esc cancel + + )} From b754374cd5ef3c7f7b1e723ef7201ea670620050 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 23 Feb 2026 21:40:19 -0500 Subject: [PATCH 2/4] test: add unit tests for unassigned target assignment and gateway removal - getUnassignedTargets: returns targets, empty when no config, empty when field missing - createGatewayFromWizard: moves selected targets to new gateway, removes from unassigned - removeGateway: preserves targets as unassigned on removal, no-op for empty gateways - previewRemoveGateway: shows 'will become unassigned' warning --- .../mcp/__tests__/create-mcp.test.ts | 75 +++++++++++++++++++ .../remove/__tests__/remove-gateway.test.ts | 68 +++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 src/cli/operations/remove/__tests__/remove-gateway.test.ts diff --git a/src/cli/operations/mcp/__tests__/create-mcp.test.ts b/src/cli/operations/mcp/__tests__/create-mcp.test.ts index a1ae39a2..ec5de92c 100644 --- a/src/cli/operations/mcp/__tests__/create-mcp.test.ts +++ b/src/cli/operations/mcp/__tests__/create-mcp.test.ts @@ -131,3 +131,78 @@ describe('createExternalGatewayTarget', () => { expect(target.outboundAuth).toEqual({ type: 'API_KEY', credentialName: 'my-cred' }); }); }); + +import { createGatewayFromWizard, getUnassignedTargets } from '../create-mcp.js'; +import type { AddGatewayConfig } from '../../../tui/screens/mcp/types.js'; + +describe('getUnassignedTargets', () => { + afterEach(() => vi.clearAllMocks()); + + it('returns unassigned targets from mcp spec', async () => { + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockResolvedValue({ + agentCoreGateways: [], + unassignedTargets: [{ name: 't1' }, { name: 't2' }], + }); + + const result = await getUnassignedTargets(); + expect(result).toHaveLength(2); + expect(result[0]!.name).toBe('t1'); + }); + + it('returns empty array when no mcp config exists', async () => { + mockConfigExists.mockReturnValue(false); + expect(await getUnassignedTargets()).toEqual([]); + }); + + it('returns empty array when unassignedTargets field is missing', async () => { + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockResolvedValue({ agentCoreGateways: [] }); + expect(await getUnassignedTargets()).toEqual([]); + }); +}); + +describe('createGatewayFromWizard with selectedTargets', () => { + afterEach(() => vi.clearAllMocks()); + + function makeGatewayConfig(overrides: Partial = {}): AddGatewayConfig { + return { + name: 'new-gateway', + authorizerType: 'AWS_IAM', + ...overrides, + } as AddGatewayConfig; + } + + it('moves selected targets to new gateway and removes from unassigned', async () => { + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockResolvedValue({ + agentCoreGateways: [], + unassignedTargets: [ + { name: 'target-a', targetType: 'mcpServer' }, + { name: 'target-b', targetType: 'mcpServer' }, + { name: 'target-c', targetType: 'mcpServer' }, + ], + }); + + await createGatewayFromWizard(makeGatewayConfig({ selectedTargets: ['target-a', 'target-c'] })); + + const written = mockWriteMcpSpec.mock.calls[0]![0]; + const gateway = written.agentCoreGateways.find((g: { name: string }) => g.name === 'new-gateway'); + expect(gateway.targets).toHaveLength(2); + expect(gateway.targets[0]!.name).toBe('target-a'); + expect(gateway.targets[1]!.name).toBe('target-c'); + expect(written.unassignedTargets).toHaveLength(1); + expect(written.unassignedTargets[0]!.name).toBe('target-b'); + }); + + it('creates gateway with empty targets when no selectedTargets', async () => { + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockResolvedValue({ agentCoreGateways: [] }); + + await createGatewayFromWizard(makeGatewayConfig()); + + const written = mockWriteMcpSpec.mock.calls[0]![0]; + const gateway = written.agentCoreGateways.find((g: { name: string }) => g.name === 'new-gateway'); + expect(gateway.targets).toHaveLength(0); + }); +}); diff --git a/src/cli/operations/remove/__tests__/remove-gateway.test.ts b/src/cli/operations/remove/__tests__/remove-gateway.test.ts new file mode 100644 index 00000000..c503a472 --- /dev/null +++ b/src/cli/operations/remove/__tests__/remove-gateway.test.ts @@ -0,0 +1,68 @@ +import { previewRemoveGateway, removeGateway } from '../remove-gateway.js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const { mockReadMcpSpec, mockWriteMcpSpec, mockConfigExists } = vi.hoisted(() => ({ + mockReadMcpSpec: vi.fn(), + mockWriteMcpSpec: vi.fn(), + mockConfigExists: vi.fn(), +})); + +vi.mock('../../../../lib/index.js', () => ({ + ConfigIO: class { + configExists = mockConfigExists; + readMcpSpec = mockReadMcpSpec; + writeMcpSpec = mockWriteMcpSpec; + }, +})); + +describe('removeGateway', () => { + afterEach(() => vi.clearAllMocks()); + + it('moves gateway targets to unassignedTargets on removal, preserving existing', async () => { + mockReadMcpSpec.mockResolvedValue({ + agentCoreGateways: [ + { name: 'gw-to-remove', targets: [{ name: 'target-1' }, { name: 'target-2' }] }, + { name: 'other-gw', targets: [] }, + ], + unassignedTargets: [{ name: 'already-unassigned' }], + }); + + const result = await removeGateway('gw-to-remove'); + + expect(result.ok).toBe(true); + const written = mockWriteMcpSpec.mock.calls[0]![0]; + expect(written.agentCoreGateways).toHaveLength(1); + expect(written.agentCoreGateways[0]!.name).toBe('other-gw'); + expect(written.unassignedTargets).toHaveLength(3); + expect(written.unassignedTargets[0]!.name).toBe('already-unassigned'); + expect(written.unassignedTargets[1]!.name).toBe('target-1'); + expect(written.unassignedTargets[2]!.name).toBe('target-2'); + }); + + it('does not modify unassignedTargets when gateway has no targets', async () => { + mockReadMcpSpec.mockResolvedValue({ + agentCoreGateways: [{ name: 'empty-gw', targets: [] }], + }); + + const result = await removeGateway('empty-gw'); + + expect(result.ok).toBe(true); + const written = mockWriteMcpSpec.mock.calls[0]![0]; + expect(written.agentCoreGateways).toHaveLength(0); + expect(written.unassignedTargets).toBeUndefined(); + }); +}); + +describe('previewRemoveGateway', () => { + afterEach(() => vi.clearAllMocks()); + + it('shows "will become unassigned" warning when gateway has targets', async () => { + mockReadMcpSpec.mockResolvedValue({ + agentCoreGateways: [{ name: 'my-gw', targets: [{ name: 't1' }, { name: 't2' }] }], + }); + + const preview = await previewRemoveGateway('my-gw'); + + expect(preview.summary.some(s => s.includes('2 target(s) will become unassigned'))).toBe(true); + }); +}); From 1f68c0649f011cded5944bd8b13736a4467e48f3 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 23 Feb 2026 21:53:35 -0500 Subject: [PATCH 3/4] style: fix formatting and merge duplicate imports --- src/cli/operations/mcp/__tests__/create-mcp.test.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/cli/operations/mcp/__tests__/create-mcp.test.ts b/src/cli/operations/mcp/__tests__/create-mcp.test.ts index ec5de92c..398e8f32 100644 --- a/src/cli/operations/mcp/__tests__/create-mcp.test.ts +++ b/src/cli/operations/mcp/__tests__/create-mcp.test.ts @@ -1,6 +1,6 @@ import { SKIP_FOR_NOW } from '../../../tui/screens/mcp/types.js'; -import type { AddGatewayTargetConfig } from '../../../tui/screens/mcp/types.js'; -import { createExternalGatewayTarget } from '../create-mcp.js'; +import type { AddGatewayConfig, AddGatewayTargetConfig } from '../../../tui/screens/mcp/types.js'; +import { createExternalGatewayTarget, createGatewayFromWizard, getUnassignedTargets } from '../create-mcp.js'; import { afterEach, describe, expect, it, vi } from 'vitest'; const { mockReadMcpSpec, mockWriteMcpSpec, mockConfigExists, mockReadProjectSpec } = vi.hoisted(() => ({ @@ -132,9 +132,6 @@ describe('createExternalGatewayTarget', () => { }); }); -import { createGatewayFromWizard, getUnassignedTargets } from '../create-mcp.js'; -import type { AddGatewayConfig } from '../../../tui/screens/mcp/types.js'; - describe('getUnassignedTargets', () => { afterEach(() => vi.clearAllMocks()); From 550ee75c98b81f12a46fd6259db48968d5e9bed6 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 24 Feb 2026 11:07:25 -0500 Subject: [PATCH 4/4] docs: add comment explaining unassigned targets preservation --- src/cli/operations/remove/remove-gateway.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/cli/operations/remove/remove-gateway.ts b/src/cli/operations/remove/remove-gateway.ts index 32780e5e..2a0a156b 100644 --- a/src/cli/operations/remove/remove-gateway.ts +++ b/src/cli/operations/remove/remove-gateway.ts @@ -58,6 +58,8 @@ function computeRemovedGatewayMcpSpec(mcpSpec: AgentCoreMcpSpec, gatewayName: st return { ...mcpSpec, agentCoreGateways: mcpSpec.agentCoreGateways.filter(g => g.name !== gatewayName), + // Preserve gateway's targets as unassigned so the user doesn't lose them. + // Only add the field if there are targets to preserve or unassignedTargets already exists. ...(targetsToPreserve.length > 0 || mcpSpec.unassignedTargets ? { unassignedTargets: [...(mcpSpec.unassignedTargets ?? []), ...targetsToPreserve] } : {}),