Skip to content

Commit a6094c8

Browse files
committed
feat: assign unassigned targets to gateways and preserve targets on removal
1 parent 8829e3e commit a6094c8

File tree

8 files changed

+272
-36
lines changed

8 files changed

+272
-36
lines changed

src/cli/operations/mcp/create-mcp.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,22 @@ function buildAuthorizerConfiguration(config: AddGatewayConfig): AgentCoreGatewa
8282
};
8383
}
8484

85+
/**
86+
* Get list of unassigned targets from MCP spec.
87+
*/
88+
export async function getUnassignedTargets(): Promise<AgentCoreGatewayTarget[]> {
89+
try {
90+
const configIO = new ConfigIO();
91+
if (!configIO.configExists('mcp')) {
92+
return [];
93+
}
94+
const mcpSpec = await configIO.readMcpSpec();
95+
return mcpSpec.unassignedTargets ?? [];
96+
} catch {
97+
return [];
98+
}
99+
}
100+
85101
/**
86102
* Get list of existing gateway names from project spec.
87103
*/
@@ -162,15 +178,34 @@ export async function createGatewayFromWizard(config: AddGatewayConfig): Promise
162178
throw new Error(`Gateway "${config.name}" already exists.`);
163179
}
164180

181+
// Collect selected unassigned targets
182+
const selectedTargets: AgentCoreGatewayTarget[] = [];
183+
if (config.selectedTargets && config.selectedTargets.length > 0) {
184+
const unassignedTargets = mcpSpec.unassignedTargets ?? [];
185+
for (const targetName of config.selectedTargets) {
186+
const target = unassignedTargets.find(t => t.name === targetName);
187+
if (target) {
188+
selectedTargets.push(target);
189+
}
190+
}
191+
}
192+
165193
const gateway: AgentCoreGateway = {
166194
name: config.name,
167195
description: config.description,
168-
targets: [],
196+
targets: selectedTargets,
169197
authorizerType: config.authorizerType,
170198
authorizerConfiguration: buildAuthorizerConfiguration(config),
171199
};
172200

173201
mcpSpec.agentCoreGateways.push(gateway);
202+
203+
// Remove selected targets from unassigned targets
204+
if (config.selectedTargets && config.selectedTargets.length > 0) {
205+
const selected = config.selectedTargets;
206+
mcpSpec.unassignedTargets = (mcpSpec.unassignedTargets ?? []).filter(t => !selected.includes(t.name));
207+
}
208+
174209
await configIO.writeMcpSpec(mcpSpec);
175210

176211
return { name: config.name };

src/cli/operations/remove/remove-gateway.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export async function previewRemoveGateway(gatewayName: string): Promise<Removal
3434
const schemaChanges: SchemaChange[] = [];
3535

3636
if (gateway.targets.length > 0) {
37-
summary.push(`Note: ${gateway.targets.length} target(s) behind this gateway will become orphaned`);
37+
summary.push(`Note: ${gateway.targets.length} target(s) will become unassigned`);
3838
}
3939

4040
// Compute schema changes
@@ -52,9 +52,15 @@ export async function previewRemoveGateway(gatewayName: string): Promise<Removal
5252
* Compute the MCP spec after removing a gateway.
5353
*/
5454
function computeRemovedGatewayMcpSpec(mcpSpec: AgentCoreMcpSpec, gatewayName: string): AgentCoreMcpSpec {
55+
const gatewayToRemove = mcpSpec.agentCoreGateways.find(g => g.name === gatewayName);
56+
const targetsToPreserve = gatewayToRemove?.targets ?? [];
57+
5558
return {
5659
...mcpSpec,
5760
agentCoreGateways: mcpSpec.agentCoreGateways.filter(g => g.name !== gatewayName),
61+
...(targetsToPreserve.length > 0 || mcpSpec.unassignedTargets
62+
? { unassignedTargets: [...(mcpSpec.unassignedTargets ?? []), ...targetsToPreserve] }
63+
: {}),
5864
};
5965
}
6066

src/cli/tui/hooks/useCreateMcp.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
getAvailableAgents,
66
getExistingGateways,
77
getExistingToolNames,
8+
getUnassignedTargets,
89
} from '../../operations/mcp/create-mcp';
910
import type { AddGatewayConfig, AddGatewayTargetConfig } from '../screens/mcp/types';
1011
import { useCallback, useEffect, useState } from 'react';
@@ -117,3 +118,22 @@ export function useExistingToolNames() {
117118

118119
return { toolNames, refresh };
119120
}
121+
122+
export function useUnassignedTargets() {
123+
const [targets, setTargets] = useState<string[]>([]);
124+
125+
useEffect(() => {
126+
async function load() {
127+
const result = await getUnassignedTargets();
128+
setTargets(result.map(t => t.name));
129+
}
130+
void load();
131+
}, []);
132+
133+
const refresh = useCallback(async () => {
134+
const result = await getUnassignedTargets();
135+
setTargets(result.map(t => t.name));
136+
}, []);
137+
138+
return { targets, refresh };
139+
}

src/cli/tui/screens/mcp/AddGatewayFlow.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { SelectableItem } from '../../components';
33
import { HELP_TEXT } from '../../constants';
44
import { useListNavigation } from '../../hooks';
55
import { useAgents, useAttachGateway, useGateways } from '../../hooks/useAttach';
6-
import { useCreateGateway, useExistingGateways } from '../../hooks/useCreateMcp';
6+
import { useCreateGateway, useExistingGateways, useUnassignedTargets } from '../../hooks/useCreateMcp';
77
import { AddSuccessScreen } from '../add/AddSuccessScreen';
88
import { AddGatewayScreen } from './AddGatewayScreen';
99
import type { AddGatewayConfig } from './types';
@@ -55,6 +55,7 @@ export function AddGatewayFlow({
5555
}: AddGatewayFlowProps) {
5656
const { createGateway, reset: resetCreate } = useCreateGateway();
5757
const { gateways: existingGateways, refresh: refreshGateways } = useExistingGateways();
58+
const { targets: unassignedTargets } = useUnassignedTargets();
5859
const [flow, setFlow] = useState<FlowState>({ name: 'mode-select' });
5960

6061
// Bind flow hooks
@@ -157,6 +158,7 @@ export function AddGatewayFlow({
157158
<AddGatewayScreen
158159
existingGateways={existingGateways}
159160
availableAgents={availableAgents}
161+
unassignedTargets={unassignedTargets}
160162
onComplete={handleCreateComplete}
161163
onExit={onBack}
162164
/>
@@ -183,6 +185,7 @@ export function AddGatewayFlow({
183185
<AddGatewayScreen
184186
existingGateways={existingGateways}
185187
availableAgents={availableAgents}
188+
unassignedTargets={unassignedTargets}
186189
onComplete={handleCreateComplete}
187190
onExit={() => setFlow({ name: 'mode-select' })}
188191
/>

src/cli/tui/screens/mcp/AddGatewayScreen.tsx

Lines changed: 51 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,17 @@ interface AddGatewayScreenProps {
2424
onExit: () => void;
2525
existingGateways: string[];
2626
availableAgents: string[];
27+
unassignedTargets: string[];
2728
}
2829

29-
export function AddGatewayScreen({ onComplete, onExit, existingGateways, availableAgents }: AddGatewayScreenProps) {
30-
const wizard = useAddGatewayWizard();
30+
export function AddGatewayScreen({
31+
onComplete,
32+
onExit,
33+
existingGateways,
34+
availableAgents,
35+
unassignedTargets,
36+
}: AddGatewayScreenProps) {
37+
const wizard = useAddGatewayWizard(unassignedTargets.length);
3138

3239
// JWT config sub-step tracking (0 = discoveryUrl, 1 = audience, 2 = clients)
3340
const [jwtSubStep, setJwtSubStep] = useState(0);
@@ -39,6 +46,11 @@ export function AddGatewayScreen({ onComplete, onExit, existingGateways, availab
3946
[availableAgents]
4047
);
4148

49+
const unassignedTargetItems: SelectableItem[] = useMemo(
50+
() => unassignedTargets.map(name => ({ id: name, title: name })),
51+
[unassignedTargets]
52+
);
53+
4254
const authorizerItems: SelectableItem[] = useMemo(
4355
() => AUTHORIZER_TYPE_OPTIONS.map(o => ({ id: o.id, title: o.title, description: o.description })),
4456
[]
@@ -48,6 +60,7 @@ export function AddGatewayScreen({ onComplete, onExit, existingGateways, availab
4860
const isAuthorizerStep = wizard.step === 'authorizer';
4961
const isJwtConfigStep = wizard.step === 'jwt-config';
5062
const isAgentsStep = wizard.step === 'agents';
63+
const isIncludeTargetsStep = wizard.step === 'include-targets';
5164
const isConfirmStep = wizard.step === 'confirm';
5265

5366
const authorizerNav = useListNavigation({
@@ -66,6 +79,15 @@ export function AddGatewayScreen({ onComplete, onExit, existingGateways, availab
6679
requireSelection: false,
6780
});
6881

82+
const targetsNav = useMultiSelectNavigation({
83+
items: unassignedTargetItems,
84+
getId: item => item.id,
85+
onConfirm: ids => wizard.setSelectedTargets(ids),
86+
onExit: () => wizard.goBack(),
87+
isActive: isIncludeTargetsStep,
88+
requireSelection: false,
89+
});
90+
6991
useListNavigation({
7092
items: [{ id: 'confirm', title: 'Confirm' }],
7193
onSelect: () => onComplete(wizard.config),
@@ -113,13 +135,14 @@ export function AddGatewayScreen({ onComplete, onExit, existingGateways, availab
113135
}
114136
};
115137

116-
const helpText = isAgentsStep
117-
? 'Space toggle · Enter confirm · Esc back'
118-
: isConfirmStep
119-
? HELP_TEXT.CONFIRM_CANCEL
120-
: isAuthorizerStep
121-
? HELP_TEXT.NAVIGATE_SELECT
122-
: HELP_TEXT.TEXT_INPUT;
138+
const helpText =
139+
isAgentsStep || isIncludeTargetsStep
140+
? 'Space toggle · Enter confirm · Esc back'
141+
: isConfirmStep
142+
? HELP_TEXT.CONFIRM_CANCEL
143+
: isAuthorizerStep
144+
? HELP_TEXT.NAVIGATE_SELECT
145+
: HELP_TEXT.TEXT_INPUT;
123146

124147
const headerContent = <StepIndicator steps={wizard.steps} currentStep={wizard.step} labels={GATEWAY_STEP_LABELS} />;
125148

@@ -178,6 +201,18 @@ export function AddGatewayScreen({ onComplete, onExit, existingGateways, availab
178201
</Text>
179202
))}
180203

204+
{isIncludeTargetsStep &&
205+
(unassignedTargetItems.length > 0 ? (
206+
<WizardMultiSelect
207+
title="Select unassigned targets to include in this gateway"
208+
items={unassignedTargetItems}
209+
cursorIndex={targetsNav.cursorIndex}
210+
selectedIds={targetsNav.selectedIds}
211+
/>
212+
) : (
213+
<Text dimColor>No unassigned targets available. Press Enter to continue.</Text>
214+
))}
215+
181216
{isConfirmStep && (
182217
<ConfirmReview
183218
fields={[
@@ -192,6 +227,13 @@ export function AddGatewayScreen({ onComplete, onExit, existingGateways, availab
192227
]
193228
: []),
194229
{ label: 'Agents', value: wizard.config.agents.length > 0 ? wizard.config.agents.join(', ') : '(none)' },
230+
{
231+
label: 'Targets',
232+
value:
233+
wizard.config.selectedTargets && wizard.config.selectedTargets.length > 0
234+
? wizard.config.selectedTargets.join(', ')
235+
: '(none)',
236+
},
195237
]}
196238
/>
197239
)}

src/cli/tui/screens/mcp/types.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { GatewayAuthorizerType, NodeRuntime, PythonRuntime, ToolDefinition
44
// Gateway Flow Types
55
// ─────────────────────────────────────────────────────────────────────────────
66

7-
export type AddGatewayStep = 'name' | 'authorizer' | 'jwt-config' | 'agents' | 'confirm';
7+
export type AddGatewayStep = 'name' | 'authorizer' | 'jwt-config' | 'agents' | 'include-targets' | 'confirm';
88

99
export interface AddGatewayConfig {
1010
name: string;
@@ -19,13 +19,16 @@ export interface AddGatewayConfig {
1919
allowedAudience: string[];
2020
allowedClients: string[];
2121
};
22+
/** Selected unassigned targets to include in this gateway */
23+
selectedTargets?: string[];
2224
}
2325

2426
export const GATEWAY_STEP_LABELS: Record<AddGatewayStep, string> = {
2527
name: 'Name',
2628
authorizer: 'Authorizer',
2729
'jwt-config': 'JWT Config',
2830
agents: 'Agents',
31+
'include-targets': 'Include Targets',
2932
confirm: 'Confirm',
3033
};
3134

src/cli/tui/screens/mcp/useAddGatewayWizard.ts

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,20 +16,32 @@ function getDefaultConfig(): AddGatewayConfig {
1616
agents: [],
1717
authorizerType: 'NONE',
1818
jwtConfig: undefined,
19+
selectedTargets: [],
1920
};
2021
}
2122

22-
export function useAddGatewayWizard() {
23+
export function useAddGatewayWizard(unassignedTargetsCount = 0) {
2324
const [config, setConfig] = useState<AddGatewayConfig>(getDefaultConfig);
2425
const [step, setStep] = useState<AddGatewayStep>('name');
2526

26-
// Dynamic steps based on authorizer type
27+
// Dynamic steps based on authorizer type and unassigned targets
2728
const steps = useMemo<AddGatewayStep[]>(() => {
29+
const baseSteps: AddGatewayStep[] = ['name', 'authorizer'];
30+
2831
if (config.authorizerType === 'CUSTOM_JWT') {
29-
return ['name', 'authorizer', 'jwt-config', 'agents', 'confirm'];
32+
baseSteps.push('jwt-config');
33+
}
34+
35+
baseSteps.push('agents');
36+
37+
if (unassignedTargetsCount > 0) {
38+
baseSteps.push('include-targets');
3039
}
31-
return ['name', 'authorizer', 'agents', 'confirm'];
32-
}, [config.authorizerType]);
40+
41+
baseSteps.push('confirm');
42+
43+
return baseSteps;
44+
}, [config.authorizerType, unassignedTargetsCount]);
3345

3446
const currentIndex = steps.indexOf(step);
3547

@@ -69,10 +81,21 @@ export function useAddGatewayWizard() {
6981
[]
7082
);
7183

72-
const setAgents = useCallback((agents: string[]) => {
84+
const setAgents = useCallback(
85+
(agents: string[]) => {
86+
setConfig(c => ({
87+
...c,
88+
agents,
89+
}));
90+
setStep(unassignedTargetsCount > 0 ? 'include-targets' : 'confirm');
91+
},
92+
[unassignedTargetsCount]
93+
);
94+
95+
const setSelectedTargets = useCallback((selectedTargets: string[]) => {
7396
setConfig(c => ({
7497
...c,
75-
agents,
98+
selectedTargets,
7699
}));
77100
setStep('confirm');
78101
}, []);
@@ -92,6 +115,7 @@ export function useAddGatewayWizard() {
92115
setAuthorizerType,
93116
setJwtConfig,
94117
setAgents,
118+
setSelectedTargets,
95119
reset,
96120
};
97121
}

0 commit comments

Comments
 (0)