diff --git a/src/cli/commands/add/types.ts b/src/cli/commands/add/types.ts index 8c682eb7..8c5fbb75 100644 --- a/src/cli/commands/add/types.ts +++ b/src/cli/commands/add/types.ts @@ -35,6 +35,7 @@ export interface AddGatewayOptions { agentClientId?: string; agentClientSecret?: string; agents?: string; + semanticSearch?: boolean; json?: boolean; } diff --git a/src/cli/primitives/GatewayPrimitive.ts b/src/cli/primitives/GatewayPrimitive.ts index 93fc97e9..121df5b2 100644 --- a/src/cli/primitives/GatewayPrimitive.ts +++ b/src/cli/primitives/GatewayPrimitive.ts @@ -26,6 +26,7 @@ export interface AddGatewayOptions { agentClientId?: string; agentClientSecret?: string; agents?: string; + enableSemanticSearch?: boolean; } /** @@ -156,6 +157,7 @@ export class GatewayPrimitive extends BasePrimitive', 'Agent OAuth client ID') .option('--agent-client-secret ', 'Agent OAuth client secret') .option('--agents ', 'Comma-separated agent names') + .option('--no-semantic-search', 'Disable semantic search for tool discovery') .option('--json', 'Output as JSON') .action(async (rawOptions: Record) => { const cliOptions = rawOptions as unknown as CLIAddGatewayOptions; @@ -186,6 +188,7 @@ export class GatewayPrimitive extends BasePrimitive void; @@ -41,6 +42,7 @@ function Harness({ isActive?: boolean; textInputActive?: boolean; requireSelection?: boolean; + initialSelectedIds?: string[]; }) { const { cursorIndex, selectedIds } = useMultiSelectNavigation({ items: testItems, @@ -50,6 +52,7 @@ function Harness({ isActive, textInputActive, requireSelection, + initialSelectedIds, }); return ( @@ -58,6 +61,27 @@ function Harness({ ); } +interface ResetHarnessHandle { + reset: () => void; +} + +const ResetHarness = React.forwardRef( + ({ initialSelectedIds }, ref) => { + const { cursorIndex, selectedIds, reset } = useMultiSelectNavigation({ + items, + getId, + initialSelectedIds, + }); + useImperativeHandle(ref, () => ({ reset })); + return ( + + cursor:{cursorIndex} selected:{Array.from(selectedIds).sort().join(',')} + + ); + } +); +ResetHarness.displayName = 'ResetHarness'; + describe('useMultiSelectNavigation', () => { it('starts with cursorIndex=0 and empty selectedIds', () => { const { lastFrame } = render(); @@ -217,4 +241,43 @@ describe('useMultiSelectNavigation', () => { await delay(); expect(onExit).not.toHaveBeenCalled(); }); + + it('initialSelectedIds pre-selects items', () => { + const { lastFrame } = render(); + expect(lastFrame()).toContain('cursor:0'); + expect(lastFrame()).toContain('selected:1,3'); + }); + + it('initialSelectedIds items can be toggled off', async () => { + const { lastFrame, stdin } = render(); + await delay(); + expect(lastFrame()).toContain('selected:1'); + + // Cursor is at 0 (item id '1'), press Space to toggle it off + stdin.write(SPACE); + await delay(); + expect(lastFrame()).not.toMatch(/selected:\S/); + }); + + it('reset restores initialSelectedIds', async () => { + const ref = React.createRef(); + const { lastFrame, stdin } = render(); + await delay(); + expect(lastFrame()).toContain('selected:2'); + + // Move cursor to item '2' (index 1) and toggle it off + stdin.write(DOWN_ARROW); + await delay(); + stdin.write(SPACE); + await delay(); + expect(lastFrame()).not.toMatch(/selected:\S/); + + // Trigger reset to restore initialSelectedIds + React.act(() => { + ref.current!.reset(); + }); + await delay(); + expect(lastFrame()).toContain('selected:2'); + expect(lastFrame()).toContain('cursor:0'); + }); }); diff --git a/src/cli/tui/hooks/useCreateMcp.ts b/src/cli/tui/hooks/useCreateMcp.ts index c6fa1be4..55963f4f 100644 --- a/src/cli/tui/hooks/useCreateMcp.ts +++ b/src/cli/tui/hooks/useCreateMcp.ts @@ -28,6 +28,7 @@ export function useCreateGateway() { allowedScopes: config.jwtConfig?.allowedScopes?.join(','), agentClientId: config.jwtConfig?.agentClientId, agentClientSecret: config.jwtConfig?.agentClientSecret, + enableSemanticSearch: config.enableSemanticSearch, }); if (!addResult.success) { throw new Error(addResult.error ?? 'Failed to create gateway'); diff --git a/src/cli/tui/hooks/useMultiSelectNavigation.ts b/src/cli/tui/hooks/useMultiSelectNavigation.ts index 6ee13f0c..5518233d 100644 --- a/src/cli/tui/hooks/useMultiSelectNavigation.ts +++ b/src/cli/tui/hooks/useMultiSelectNavigation.ts @@ -16,6 +16,8 @@ interface UseMultiSelectNavigationOptions { textInputActive?: boolean; /** Whether to require at least one selection before confirm (default: false) */ requireSelection?: boolean; + /** Initial set of selected item IDs (default: empty set) */ + initialSelectedIds?: string[]; } interface UseMultiSelectNavigationResult { @@ -56,9 +58,10 @@ export function useMultiSelectNavigation({ isActive = true, textInputActive = false, requireSelection = false, + initialSelectedIds, }: UseMultiSelectNavigationOptions): UseMultiSelectNavigationResult { const [cursorIndex, setCursorIndex] = useState(0); - const [selectedIds, setSelectedIds] = useState>(new Set()); + const [selectedIds, setSelectedIds] = useState>(() => new Set(initialSelectedIds ?? [])); const toggleSelection = useCallback(() => { const item = items[cursorIndex]; @@ -77,8 +80,8 @@ export function useMultiSelectNavigation({ const reset = useCallback(() => { setCursorIndex(0); - setSelectedIds(new Set()); - }, []); + setSelectedIds(new Set(initialSelectedIds ?? [])); + }, [initialSelectedIds]); useInput( (input, key) => { diff --git a/src/cli/tui/screens/mcp/AddGatewayScreen.tsx b/src/cli/tui/screens/mcp/AddGatewayScreen.tsx index dca25086..7303c333 100644 --- a/src/cli/tui/screens/mcp/AddGatewayScreen.tsx +++ b/src/cli/tui/screens/mcp/AddGatewayScreen.tsx @@ -15,7 +15,7 @@ import { HELP_TEXT } from '../../constants'; import { useListNavigation, useMultiSelectNavigation } from '../../hooks'; import { generateUniqueName } from '../../utils'; import type { AddGatewayConfig } from './types'; -import { AUTHORIZER_TYPE_OPTIONS, GATEWAY_STEP_LABELS } from './types'; +import { AUTHORIZER_TYPE_OPTIONS, GATEWAY_STEP_LABELS, SEMANTIC_SEARCH_ITEM_ID } from './types'; import { useAddGatewayWizard } from './useAddGatewayWizard'; import { Box, Text } from 'ink'; import React, { useMemo, useState } from 'react'; @@ -27,6 +27,8 @@ interface AddGatewayScreenProps { unassignedTargets: string[]; } +const INITIAL_ADVANCED_SELECTED = [SEMANTIC_SEARCH_ITEM_ID]; + export function AddGatewayScreen({ onComplete, onExit, existingGateways, unassignedTargets }: AddGatewayScreenProps) { const wizard = useAddGatewayWizard(unassignedTargets.length); @@ -48,10 +50,16 @@ export function AddGatewayScreen({ onComplete, onExit, existingGateways, unassig [] ); + const advancedConfigItems: SelectableItem[] = useMemo( + () => [{ id: SEMANTIC_SEARCH_ITEM_ID, title: 'Semantic Search' }], + [] + ); + const isNameStep = wizard.step === 'name'; const isAuthorizerStep = wizard.step === 'authorizer'; const isJwtConfigStep = wizard.step === 'jwt-config'; const isIncludeTargetsStep = wizard.step === 'include-targets'; + const isAdvancedConfigStep = wizard.step === 'advanced-config'; const isConfirmStep = wizard.step === 'confirm'; const authorizerNav = useListNavigation({ @@ -70,6 +78,17 @@ export function AddGatewayScreen({ onComplete, onExit, existingGateways, unassig requireSelection: false, }); + const advancedNav = useMultiSelectNavigation({ + items: advancedConfigItems, + getId: item => item.id, + initialSelectedIds: INITIAL_ADVANCED_SELECTED, + onConfirm: selectedIds => + wizard.setAdvancedConfig({ enableSemanticSearch: selectedIds.includes(SEMANTIC_SEARCH_ITEM_ID) }), + onExit: () => wizard.goBack(), + isActive: isAdvancedConfigStep, + requireSelection: false, + }); + useListNavigation({ items: [{ id: 'confirm', title: 'Confirm' }], onSelect: () => onComplete(wizard.config), @@ -136,13 +155,14 @@ export function AddGatewayScreen({ onComplete, onExit, existingGateways, unassig } }; - const helpText = isIncludeTargetsStep - ? 'Space toggle · Enter confirm · Esc back' - : isConfirmStep - ? HELP_TEXT.CONFIRM_CANCEL - : isAuthorizerStep - ? HELP_TEXT.NAVIGATE_SELECT - : HELP_TEXT.TEXT_INPUT; + const helpText = + isIncludeTargetsStep || isAdvancedConfigStep + ? 'Space toggle · Enter confirm · Esc back' + : isConfirmStep + ? HELP_TEXT.CONFIRM_CANCEL + : isAuthorizerStep + ? HELP_TEXT.NAVIGATE_SELECT + : HELP_TEXT.TEXT_INPUT; const headerContent = ; @@ -202,6 +222,30 @@ export function AddGatewayScreen({ onComplete, onExit, existingGateways, unassig No unassigned targets available. Press Enter to continue. ))} + {isAdvancedConfigStep && ( + + Advanced Configuration + Toggle options with Space, press Enter to continue + + {advancedConfigItems.map((item, idx) => { + const isCursor = idx === advancedNav.cursorIndex; + const isChecked = advancedNav.selectedIds.has(item.id); + const checkbox = isChecked ? '[✓]' : '[ ]'; + return ( + + + {isCursor ? '❯' : ' '} + {checkbox} + {item.title} + + {isChecked ? 'Enabled' : 'Disabled'} + + ); + })} + + + )} + {isConfirmStep && ( )} diff --git a/src/cli/tui/screens/mcp/types.ts b/src/cli/tui/screens/mcp/types.ts index 10e58a47..1290b752 100644 --- a/src/cli/tui/screens/mcp/types.ts +++ b/src/cli/tui/screens/mcp/types.ts @@ -13,7 +13,7 @@ import { TARGET_TYPE_AUTH_CONFIG } from '../../../../schema'; // Gateway Flow Types // ───────────────────────────────────────────────────────────────────────────── -export type AddGatewayStep = 'name' | 'authorizer' | 'jwt-config' | 'include-targets' | 'confirm'; +export type AddGatewayStep = 'name' | 'authorizer' | 'jwt-config' | 'include-targets' | 'advanced-config' | 'confirm'; export interface AddGatewayConfig { name: string; @@ -31,13 +31,19 @@ export interface AddGatewayConfig { }; /** Selected unassigned targets to include in this gateway */ selectedTargets?: string[]; + /** Whether to enable semantic search for tool discovery */ + enableSemanticSearch: boolean; } +/** Item ID for the semantic search toggle in the advanced config pane. */ +export const SEMANTIC_SEARCH_ITEM_ID = 'semantic-search'; + export const GATEWAY_STEP_LABELS: Record = { name: 'Name', authorizer: 'Authorizer', 'jwt-config': 'JWT Config', 'include-targets': 'Include Targets', + 'advanced-config': 'Advanced', confirm: 'Confirm', }; diff --git a/src/cli/tui/screens/mcp/useAddGatewayWizard.ts b/src/cli/tui/screens/mcp/useAddGatewayWizard.ts index 90265bca..d40f789a 100644 --- a/src/cli/tui/screens/mcp/useAddGatewayWizard.ts +++ b/src/cli/tui/screens/mcp/useAddGatewayWizard.ts @@ -2,13 +2,6 @@ import type { GatewayAuthorizerType } from '../../../../schema'; import type { AddGatewayConfig, AddGatewayStep } from './types'; import { useCallback, useMemo, useState } from 'react'; -/** Maps authorizer type to the next step after authorizer selection */ -const AUTHORIZER_NEXT_STEP: Record = { - NONE: 'confirm', - AWS_IAM: 'confirm', - CUSTOM_JWT: 'jwt-config', -}; - function getDefaultConfig(): AddGatewayConfig { return { name: '', @@ -16,6 +9,7 @@ function getDefaultConfig(): AddGatewayConfig { authorizerType: 'NONE', jwtConfig: undefined, selectedTargets: [], + enableSemanticSearch: true, }; } @@ -35,6 +29,7 @@ export function useAddGatewayWizard(unassignedTargetsCount = 0) { baseSteps.push('include-targets'); } + baseSteps.push('advanced-config'); baseSteps.push('confirm'); return baseSteps; @@ -56,16 +51,25 @@ export function useAddGatewayWizard(unassignedTargetsCount = 0) { setStep('authorizer'); }, []); - const setAuthorizerType = useCallback((authorizerType: GatewayAuthorizerType) => { - setConfig(c => ({ - ...c, - authorizerType, - // Clear JWT config if switching away from CUSTOM_JWT - jwtConfig: authorizerType === 'CUSTOM_JWT' ? c.jwtConfig : undefined, - })); - // Navigate to next step based on authorizer type - setStep(AUTHORIZER_NEXT_STEP[authorizerType]); - }, []); + const setAuthorizerType = useCallback( + (authorizerType: GatewayAuthorizerType) => { + setConfig(c => ({ + ...c, + authorizerType, + // Clear JWT config if switching away from CUSTOM_JWT + jwtConfig: authorizerType === 'CUSTOM_JWT' ? c.jwtConfig : undefined, + })); + // Navigate to next step based on authorizer type + if (authorizerType === 'CUSTOM_JWT') { + setStep('jwt-config'); + } else if (unassignedTargetsCount > 0) { + setStep('include-targets'); + } else { + setStep('advanced-config'); + } + }, + [unassignedTargetsCount] + ); const setJwtConfig = useCallback( (jwtConfig: { @@ -80,7 +84,7 @@ export function useAddGatewayWizard(unassignedTargetsCount = 0) { ...c, jwtConfig, })); - setStep(unassignedTargetsCount > 0 ? 'include-targets' : 'confirm'); + setStep(unassignedTargetsCount > 0 ? 'include-targets' : 'advanced-config'); }, [unassignedTargetsCount] ); @@ -90,6 +94,11 @@ export function useAddGatewayWizard(unassignedTargetsCount = 0) { ...c, selectedTargets, })); + setStep('advanced-config'); + }, []); + + const setAdvancedConfig = useCallback((opts: { enableSemanticSearch: boolean }) => { + setConfig(c => ({ ...c, enableSemanticSearch: opts.enableSemanticSearch })); setStep('confirm'); }, []); @@ -108,6 +117,7 @@ export function useAddGatewayWizard(unassignedTargetsCount = 0) { setAuthorizerType, setJwtConfig, setSelectedTargets, + setAdvancedConfig, reset, }; } diff --git a/src/schema/llm-compacted/mcp.ts b/src/schema/llm-compacted/mcp.ts index 04e6c7bb..c92bc1ed 100644 --- a/src/schema/llm-compacted/mcp.ts +++ b/src/schema/llm-compacted/mcp.ts @@ -23,6 +23,8 @@ interface AgentCoreGateway { name: string; // @regex ^([0-9a-zA-Z][-]?){1,100}$ @max 100 description?: string; targets: AgentCoreGatewayTarget[]; + /** Enable semantic search for tool discovery. @default true */ + enableSemanticSearch?: boolean; // default true } interface AgentCoreGatewayTarget { diff --git a/src/schema/schemas/__tests__/mcp.test.ts b/src/schema/schemas/__tests__/mcp.test.ts index deca44fa..c7bf40c8 100644 --- a/src/schema/schemas/__tests__/mcp.test.ts +++ b/src/schema/schemas/__tests__/mcp.test.ts @@ -367,6 +367,44 @@ describe('AgentCoreGatewaySchema', () => { }); expect(result.success).toBe(false); }); + + it('defaults enableSemanticSearch to true when omitted', () => { + const result = AgentCoreGatewaySchema.safeParse(validGateway); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.enableSemanticSearch).toBe(true); + } + }); + + it('accepts explicit enableSemanticSearch true', () => { + const result = AgentCoreGatewaySchema.safeParse({ + ...validGateway, + enableSemanticSearch: true, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.enableSemanticSearch).toBe(true); + } + }); + + it('accepts explicit enableSemanticSearch false', () => { + const result = AgentCoreGatewaySchema.safeParse({ + ...validGateway, + enableSemanticSearch: false, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.enableSemanticSearch).toBe(false); + } + }); + + it('rejects non-boolean enableSemanticSearch', () => { + const result = AgentCoreGatewaySchema.safeParse({ + ...validGateway, + enableSemanticSearch: 'yes', + }); + expect(result.success).toBe(false); + }); }); describe('AgentCoreMcpRuntimeToolSchema', () => { diff --git a/src/schema/schemas/mcp.ts b/src/schema/schemas/mcp.ts index ab1c8c62..c261c2fa 100644 --- a/src/schema/schemas/mcp.ts +++ b/src/schema/schemas/mcp.ts @@ -603,6 +603,8 @@ export const AgentCoreGatewaySchema = z authorizerType: GatewayAuthorizerTypeSchema.default('NONE'), /** Authorizer configuration. Required when authorizerType is 'CUSTOM_JWT'. */ authorizerConfiguration: GatewayAuthorizerConfigSchema.optional(), + /** Whether to enable semantic search for tool discovery. Defaults to true. */ + enableSemanticSearch: z.boolean().default(true), }) .strict() .refine(