Skip to content

Commit 8d35d7f

Browse files
authored
feat: add semantic search toggle for gateways (#533)
* feat: add enableSemanticSearch field to gateway schema Add enableSemanticSearch boolean (default true) to AgentCoreGatewaySchema. When enabled, the service indexes tool descriptions for natural language discovery. Update LLM-compacted types and add schema validation tests. * feat: add initialSelectedIds support to useMultiSelectNavigation Allow callers to specify pre-selected items and ensure reset() restores initial selections instead of clearing to empty set. * feat: add advanced config pane with semantic search toggle to gateway wizard Add 'advanced-config' step to the gateway creation TUI wizard with a toggleable checkbox for semantic search (enabled by default). Replace static AUTHORIZER_NEXT_STEP map with dynamic routing to fix navigation when unassigned targets exist. * feat: add --no-semantic-search CLI flag for gateway creation Wire enableSemanticSearch through GatewayPrimitive, CLI option types, and the useCreateGateway hook so both TUI and CLI paths persist the setting to mcp.json. * refactor: use positive enableSemanticSearch naming internally Rename noSemanticSearch to enableSemanticSearch in primitive options and hoist initialSelectedIds array to module-level constant to avoid new reference on every render.
1 parent e4a3bbe commit 8d35d7f

11 files changed

Lines changed: 207 additions & 31 deletions

File tree

src/cli/commands/add/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export interface AddGatewayOptions {
3535
agentClientId?: string;
3636
agentClientSecret?: string;
3737
agents?: string;
38+
semanticSearch?: boolean;
3839
json?: boolean;
3940
}
4041

src/cli/primitives/GatewayPrimitive.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export interface AddGatewayOptions {
2626
agentClientId?: string;
2727
agentClientSecret?: string;
2828
agents?: string;
29+
enableSemanticSearch?: boolean;
2930
}
3031

3132
/**
@@ -156,6 +157,7 @@ export class GatewayPrimitive extends BasePrimitive<AddGatewayOptions, Removable
156157
.option('--agent-client-id <id>', 'Agent OAuth client ID')
157158
.option('--agent-client-secret <secret>', 'Agent OAuth client secret')
158159
.option('--agents <agents>', 'Comma-separated agent names')
160+
.option('--no-semantic-search', 'Disable semantic search for tool discovery')
159161
.option('--json', 'Output as JSON')
160162
.action(async (rawOptions: Record<string, string | boolean | undefined>) => {
161163
const cliOptions = rawOptions as unknown as CLIAddGatewayOptions;
@@ -186,6 +188,7 @@ export class GatewayPrimitive extends BasePrimitive<AddGatewayOptions, Removable
186188
agentClientId: cliOptions.agentClientId,
187189
agentClientSecret: cliOptions.agentClientSecret,
188190
agents: cliOptions.agents,
191+
enableSemanticSearch: cliOptions.semanticSearch !== false,
189192
});
190193

191194
if (cliOptions.json) {
@@ -282,6 +285,7 @@ export class GatewayPrimitive extends BasePrimitive<AddGatewayOptions, Removable
282285
description: options.description ?? `Gateway for ${options.name}`,
283286
authorizerType: options.authorizerType,
284287
jwtConfig: undefined,
288+
enableSemanticSearch: options.enableSemanticSearch ?? true,
285289
};
286290

287291
if (options.authorizerType === 'CUSTOM_JWT' && options.discoveryUrl) {
@@ -348,6 +352,7 @@ export class GatewayPrimitive extends BasePrimitive<AddGatewayOptions, Removable
348352
targets: movedTargets,
349353
authorizerType: config.authorizerType,
350354
authorizerConfiguration: this.buildAuthorizerConfiguration(config),
355+
enableSemanticSearch: config.enableSemanticSearch,
351356
};
352357

353358
mcpSpec.agentCoreGateways.push(gateway);

src/cli/tui/hooks/__tests__/useMultiSelectNavigation.test.tsx

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useMultiSelectNavigation } from '../useMultiSelectNavigation.js';
22
import { Text } from 'ink';
33
import { render } from 'ink-testing-library';
4-
import React from 'react';
4+
import React, { useImperativeHandle } from 'react';
55
import { afterEach, describe, expect, it, vi } from 'vitest';
66

77
const UP_ARROW = '\x1B[A';
@@ -34,13 +34,15 @@ function Harness({
3434
isActive,
3535
textInputActive,
3636
requireSelection,
37+
initialSelectedIds,
3738
}: {
3839
testItems?: Item[];
3940
onConfirm?: (ids: string[]) => void;
4041
onExit?: () => void;
4142
isActive?: boolean;
4243
textInputActive?: boolean;
4344
requireSelection?: boolean;
45+
initialSelectedIds?: string[];
4446
}) {
4547
const { cursorIndex, selectedIds } = useMultiSelectNavigation({
4648
items: testItems,
@@ -50,6 +52,7 @@ function Harness({
5052
isActive,
5153
textInputActive,
5254
requireSelection,
55+
initialSelectedIds,
5356
});
5457
return (
5558
<Text>
@@ -58,6 +61,27 @@ function Harness({
5861
);
5962
}
6063

64+
interface ResetHarnessHandle {
65+
reset: () => void;
66+
}
67+
68+
const ResetHarness = React.forwardRef<ResetHarnessHandle, { initialSelectedIds?: string[] }>(
69+
({ initialSelectedIds }, ref) => {
70+
const { cursorIndex, selectedIds, reset } = useMultiSelectNavigation({
71+
items,
72+
getId,
73+
initialSelectedIds,
74+
});
75+
useImperativeHandle(ref, () => ({ reset }));
76+
return (
77+
<Text>
78+
cursor:{cursorIndex} selected:{Array.from(selectedIds).sort().join(',')}
79+
</Text>
80+
);
81+
}
82+
);
83+
ResetHarness.displayName = 'ResetHarness';
84+
6185
describe('useMultiSelectNavigation', () => {
6286
it('starts with cursorIndex=0 and empty selectedIds', () => {
6387
const { lastFrame } = render(<Harness />);
@@ -217,4 +241,43 @@ describe('useMultiSelectNavigation', () => {
217241
await delay();
218242
expect(onExit).not.toHaveBeenCalled();
219243
});
244+
245+
it('initialSelectedIds pre-selects items', () => {
246+
const { lastFrame } = render(<Harness initialSelectedIds={['1', '3']} />);
247+
expect(lastFrame()).toContain('cursor:0');
248+
expect(lastFrame()).toContain('selected:1,3');
249+
});
250+
251+
it('initialSelectedIds items can be toggled off', async () => {
252+
const { lastFrame, stdin } = render(<Harness initialSelectedIds={['1']} />);
253+
await delay();
254+
expect(lastFrame()).toContain('selected:1');
255+
256+
// Cursor is at 0 (item id '1'), press Space to toggle it off
257+
stdin.write(SPACE);
258+
await delay();
259+
expect(lastFrame()).not.toMatch(/selected:\S/);
260+
});
261+
262+
it('reset restores initialSelectedIds', async () => {
263+
const ref = React.createRef<ResetHarnessHandle>();
264+
const { lastFrame, stdin } = render(<ResetHarness ref={ref} initialSelectedIds={['2']} />);
265+
await delay();
266+
expect(lastFrame()).toContain('selected:2');
267+
268+
// Move cursor to item '2' (index 1) and toggle it off
269+
stdin.write(DOWN_ARROW);
270+
await delay();
271+
stdin.write(SPACE);
272+
await delay();
273+
expect(lastFrame()).not.toMatch(/selected:\S/);
274+
275+
// Trigger reset to restore initialSelectedIds
276+
React.act(() => {
277+
ref.current!.reset();
278+
});
279+
await delay();
280+
expect(lastFrame()).toContain('selected:2');
281+
expect(lastFrame()).toContain('cursor:0');
282+
});
220283
});

src/cli/tui/hooks/useCreateMcp.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export function useCreateGateway() {
2828
allowedScopes: config.jwtConfig?.allowedScopes?.join(','),
2929
agentClientId: config.jwtConfig?.agentClientId,
3030
agentClientSecret: config.jwtConfig?.agentClientSecret,
31+
enableSemanticSearch: config.enableSemanticSearch,
3132
});
3233
if (!addResult.success) {
3334
throw new Error(addResult.error ?? 'Failed to create gateway');

src/cli/tui/hooks/useMultiSelectNavigation.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ interface UseMultiSelectNavigationOptions<T> {
1616
textInputActive?: boolean;
1717
/** Whether to require at least one selection before confirm (default: false) */
1818
requireSelection?: boolean;
19+
/** Initial set of selected item IDs (default: empty set) */
20+
initialSelectedIds?: string[];
1921
}
2022

2123
interface UseMultiSelectNavigationResult {
@@ -56,9 +58,10 @@ export function useMultiSelectNavigation<T>({
5658
isActive = true,
5759
textInputActive = false,
5860
requireSelection = false,
61+
initialSelectedIds,
5962
}: UseMultiSelectNavigationOptions<T>): UseMultiSelectNavigationResult {
6063
const [cursorIndex, setCursorIndex] = useState(0);
61-
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
64+
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set(initialSelectedIds ?? []));
6265

6366
const toggleSelection = useCallback(() => {
6467
const item = items[cursorIndex];
@@ -77,8 +80,8 @@ export function useMultiSelectNavigation<T>({
7780

7881
const reset = useCallback(() => {
7982
setCursorIndex(0);
80-
setSelectedIds(new Set());
81-
}, []);
83+
setSelectedIds(new Set(initialSelectedIds ?? []));
84+
}, [initialSelectedIds]);
8285

8386
useInput(
8487
(input, key) => {

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

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { HELP_TEXT } from '../../constants';
1515
import { useListNavigation, useMultiSelectNavigation } from '../../hooks';
1616
import { generateUniqueName } from '../../utils';
1717
import type { AddGatewayConfig } from './types';
18-
import { AUTHORIZER_TYPE_OPTIONS, GATEWAY_STEP_LABELS } from './types';
18+
import { AUTHORIZER_TYPE_OPTIONS, GATEWAY_STEP_LABELS, SEMANTIC_SEARCH_ITEM_ID } from './types';
1919
import { useAddGatewayWizard } from './useAddGatewayWizard';
2020
import { Box, Text } from 'ink';
2121
import React, { useMemo, useState } from 'react';
@@ -27,6 +27,8 @@ interface AddGatewayScreenProps {
2727
unassignedTargets: string[];
2828
}
2929

30+
const INITIAL_ADVANCED_SELECTED = [SEMANTIC_SEARCH_ITEM_ID];
31+
3032
export function AddGatewayScreen({ onComplete, onExit, existingGateways, unassignedTargets }: AddGatewayScreenProps) {
3133
const wizard = useAddGatewayWizard(unassignedTargets.length);
3234

@@ -48,10 +50,16 @@ export function AddGatewayScreen({ onComplete, onExit, existingGateways, unassig
4850
[]
4951
);
5052

53+
const advancedConfigItems: SelectableItem[] = useMemo(
54+
() => [{ id: SEMANTIC_SEARCH_ITEM_ID, title: 'Semantic Search' }],
55+
[]
56+
);
57+
5158
const isNameStep = wizard.step === 'name';
5259
const isAuthorizerStep = wizard.step === 'authorizer';
5360
const isJwtConfigStep = wizard.step === 'jwt-config';
5461
const isIncludeTargetsStep = wizard.step === 'include-targets';
62+
const isAdvancedConfigStep = wizard.step === 'advanced-config';
5563
const isConfirmStep = wizard.step === 'confirm';
5664

5765
const authorizerNav = useListNavigation({
@@ -70,6 +78,17 @@ export function AddGatewayScreen({ onComplete, onExit, existingGateways, unassig
7078
requireSelection: false,
7179
});
7280

81+
const advancedNav = useMultiSelectNavigation({
82+
items: advancedConfigItems,
83+
getId: item => item.id,
84+
initialSelectedIds: INITIAL_ADVANCED_SELECTED,
85+
onConfirm: selectedIds =>
86+
wizard.setAdvancedConfig({ enableSemanticSearch: selectedIds.includes(SEMANTIC_SEARCH_ITEM_ID) }),
87+
onExit: () => wizard.goBack(),
88+
isActive: isAdvancedConfigStep,
89+
requireSelection: false,
90+
});
91+
7392
useListNavigation({
7493
items: [{ id: 'confirm', title: 'Confirm' }],
7594
onSelect: () => onComplete(wizard.config),
@@ -136,13 +155,14 @@ export function AddGatewayScreen({ onComplete, onExit, existingGateways, unassig
136155
}
137156
};
138157

139-
const helpText = 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;
158+
const helpText =
159+
isIncludeTargetsStep || isAdvancedConfigStep
160+
? 'Space toggle · Enter confirm · Esc back'
161+
: isConfirmStep
162+
? HELP_TEXT.CONFIRM_CANCEL
163+
: isAuthorizerStep
164+
? HELP_TEXT.NAVIGATE_SELECT
165+
: HELP_TEXT.TEXT_INPUT;
146166

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

@@ -202,6 +222,30 @@ export function AddGatewayScreen({ onComplete, onExit, existingGateways, unassig
202222
<Text dimColor>No unassigned targets available. Press Enter to continue.</Text>
203223
))}
204224

225+
{isAdvancedConfigStep && (
226+
<Box flexDirection="column">
227+
<Text bold>Advanced Configuration</Text>
228+
<Text dimColor>Toggle options with Space, press Enter to continue</Text>
229+
<Box marginTop={1} flexDirection="column">
230+
{advancedConfigItems.map((item, idx) => {
231+
const isCursor = idx === advancedNav.cursorIndex;
232+
const isChecked = advancedNav.selectedIds.has(item.id);
233+
const checkbox = isChecked ? '[✓]' : '[ ]';
234+
return (
235+
<Box key={item.id}>
236+
<Text wrap="truncate">
237+
<Text color={isCursor ? 'cyan' : undefined}>{isCursor ? '❯' : ' '} </Text>
238+
<Text color={isChecked ? 'green' : undefined}>{checkbox} </Text>
239+
<Text color={isCursor ? 'cyan' : undefined}>{item.title}</Text>
240+
</Text>
241+
<Text dimColor> {isChecked ? 'Enabled' : 'Disabled'}</Text>
242+
</Box>
243+
);
244+
})}
245+
</Box>
246+
</Box>
247+
)}
248+
205249
{isConfirmStep && (
206250
<ConfirmReview
207251
fields={[
@@ -228,6 +272,7 @@ export function AddGatewayScreen({ onComplete, onExit, existingGateways, unassig
228272
? wizard.config.selectedTargets.join(', ')
229273
: '(none)',
230274
},
275+
{ label: 'Semantic Search', value: wizard.config.enableSemanticSearch ? 'Enabled' : 'Disabled' },
231276
]}
232277
/>
233278
)}

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { TARGET_TYPE_AUTH_CONFIG } from '../../../../schema';
1313
// Gateway Flow Types
1414
// ─────────────────────────────────────────────────────────────────────────────
1515

16-
export type AddGatewayStep = 'name' | 'authorizer' | 'jwt-config' | 'include-targets' | 'confirm';
16+
export type AddGatewayStep = 'name' | 'authorizer' | 'jwt-config' | 'include-targets' | 'advanced-config' | 'confirm';
1717

1818
export interface AddGatewayConfig {
1919
name: string;
@@ -31,13 +31,19 @@ export interface AddGatewayConfig {
3131
};
3232
/** Selected unassigned targets to include in this gateway */
3333
selectedTargets?: string[];
34+
/** Whether to enable semantic search for tool discovery */
35+
enableSemanticSearch: boolean;
3436
}
3537

38+
/** Item ID for the semantic search toggle in the advanced config pane. */
39+
export const SEMANTIC_SEARCH_ITEM_ID = 'semantic-search';
40+
3641
export const GATEWAY_STEP_LABELS: Record<AddGatewayStep, string> = {
3742
name: 'Name',
3843
authorizer: 'Authorizer',
3944
'jwt-config': 'JWT Config',
4045
'include-targets': 'Include Targets',
46+
'advanced-config': 'Advanced',
4147
confirm: 'Confirm',
4248
};
4349

0 commit comments

Comments
 (0)