diff --git a/src/cli/commands/add/types.ts b/src/cli/commands/add/types.ts index e70cd63b2..b619f0f44 100644 --- a/src/cli/commands/add/types.ts +++ b/src/cli/commands/add/types.ts @@ -108,6 +108,11 @@ export interface AddGatewayTargetOptions { stickinessTimeout?: string; signingService?: string; signingRegion?: string; + /** + * Comma-separated list of domains to restrict web search results to. + * Only applies to --type web-search. + */ + includeDomains?: string; /** * Comma-separated list of domains to exclude from web search results. * Only applies to --type web-search. diff --git a/src/cli/commands/add/validate.ts b/src/cli/commands/add/validate.ts index 3b027fdb2..83b609a29 100644 --- a/src/cli/commands/add/validate.ts +++ b/src/cli/commands/add/validate.ts @@ -437,12 +437,16 @@ export async function validateAddGatewayTargetOptions(options: AddGatewayTargetO } options.type = mappedType; - // --exclude-domains only applies to the web-search connector. - if (options.excludeDomains && !(mappedType === 'connector' && options.connector === 'web-search')) { - return { - valid: false, - error: '--exclude-domains only applies to --connector web-search', - }; + // The domain filters only apply to the web-search connector. + const isWebSearchConnector = mappedType === 'connector' && options.connector === 'web-search'; + for (const flag of ['includeDomains', 'excludeDomains'] as const) { + if (options[flag] && !isWebSearchConnector) { + const name = flag === 'includeDomains' ? '--include-domains' : '--exclude-domains'; + return { + valid: false, + error: `${name} only applies to --connector web-search`, + }; + } } // Gateway is required — a gateway target must be attached to a gateway diff --git a/src/cli/operations/connectors/__tests__/translators.test.ts b/src/cli/operations/connectors/__tests__/translators.test.ts new file mode 100644 index 000000000..e2f0779e5 --- /dev/null +++ b/src/cli/operations/connectors/__tests__/translators.test.ts @@ -0,0 +1,48 @@ +import { translateConnector } from '../translators'; +import { describe, expect, it } from 'vitest'; + +/** The single WebSearch entry the web-search connector always produces. */ +function webSearch(input: { includeDomains?: string[]; excludeDomains?: string[] }) { + const entries = translateConnector({ connectorId: 'web-search', input }); + expect(entries).toHaveLength(1); + expect(entries[0]?.name).toBe('WebSearch'); + return entries[0]!; +} + +describe('translateConnector — web-search', () => { + it('emits no domainFilter when neither list is given', () => { + expect(webSearch({}).parameterValues).toEqual({}); + }); + + it('emits an exclude-only filter', () => { + expect(webSearch({ excludeDomains: ['internal.example.com'] }).parameterValues).toEqual({ + domainFilter: { exclude: ['internal.example.com'] }, + }); + }); + + it('emits an include-only filter', () => { + expect(webSearch({ includeDomains: ['docs.aws.amazon.com'] }).parameterValues).toEqual({ + domainFilter: { include: ['docs.aws.amazon.com'] }, + }); + }); + + it('puts both lists in one domainFilter, since the connector reads them together', () => { + expect( + webSearch({ includeDomains: ['aws.amazon.com'], excludeDomains: ['internal.example.com'] }).parameterValues + ).toEqual({ + domainFilter: { include: ['aws.amazon.com'], exclude: ['internal.example.com'] }, + }); + }); + + it('drops empty lists rather than sending them', () => { + // An empty include list is not the same request as no include list: sending one + // would tell the connector to return nothing. + expect(webSearch({ includeDomains: [], excludeDomains: [] }).parameterValues).toEqual({}); + }); + + it('keeps one list when the other is empty', () => { + expect(webSearch({ includeDomains: ['aws.amazon.com'], excludeDomains: [] }).parameterValues).toEqual({ + domainFilter: { include: ['aws.amazon.com'] }, + }); + }); +}); diff --git a/src/cli/operations/connectors/translators.ts b/src/cli/operations/connectors/translators.ts index 5e8240542..59d4c23ee 100644 --- a/src/cli/operations/connectors/translators.ts +++ b/src/cli/operations/connectors/translators.ts @@ -20,6 +20,7 @@ export interface ParameterOverride { } export interface WebSearchTranslatorInput { + includeDomains?: string[]; excludeDomains?: string[]; } @@ -33,9 +34,20 @@ export type ConnectorTranslatorInput = function translateWebSearch(input: WebSearchTranslatorInput): ConfigurationEntry[] { const parameterValues: Record = {}; + + // Both lists go in one domainFilter, since that is how the connector reads them. + // An empty list is not the same request as an absent one, so empty is left out. + const domainFilter: { include?: string[]; exclude?: string[] } = {}; + if (input.includeDomains && input.includeDomains.length > 0) { + domainFilter.include = input.includeDomains; + } if (input.excludeDomains && input.excludeDomains.length > 0) { - parameterValues.domainFilter = { exclude: input.excludeDomains }; + domainFilter.exclude = input.excludeDomains; } + if (Object.keys(domainFilter).length > 0) { + parameterValues.domainFilter = domainFilter; + } + return [{ name: 'WebSearch', description: '', parameterValues, parameterOverrides: [] }]; } diff --git a/src/cli/primitives/GatewayTargetPrimitive.ts b/src/cli/primitives/GatewayTargetPrimitive.ts index 280d13ad6..58977a481 100644 --- a/src/cli/primitives/GatewayTargetPrimitive.ts +++ b/src/cli/primitives/GatewayTargetPrimitive.ts @@ -277,16 +277,18 @@ export class GatewayTargetPrimitive extends BasePrimitive { + const onceOnly = (flag: string) => (val: string, prev?: string) => { if (prev !== undefined) { throw new ValidationError( - '--exclude-domains may only be specified once. Pass all domains as a single comma-separated value.' + `${flag} may only be specified once. Pass all domains as a single comma-separated value.` ); } return val; }; + const includeDomainsCoercer = onceOnly('--include-domains'); + const excludeDomainsCoercer = onceOnly('--exclude-domains'); addCmd .command('gateway-target') @@ -305,6 +307,11 @@ export class GatewayTargetPrimitive extends BasePrimitive [...acc, val], [] as string[] ) + .option( + '--include-domains ', + 'Comma-separated domains to restrict results to (for --connector web-search) [non-interactive]', + includeDomainsCoercer + ) .option( '--exclude-domains ', 'Comma-separated domains to exclude from results (for --connector web-search) [non-interactive]', @@ -411,6 +418,7 @@ Target types and their options: connector — Wire a managed AWS connector (bedrock-knowledge-bases, web-search) --connector bedrock-knowledge-bases or web-search --knowledge-base-id Project KB name or 10-char external KB id (for KB connectors) + --include-domains Comma-separated domains to restrict results to (for web-search connector) --exclude-domains Comma-separated domains to exclude (for web-search connector) passthrough — Route to an external HTTPS endpoint @@ -638,24 +646,31 @@ Target types and their options: // Web search connector if (connectorId === 'web-search') { - const excludeDomains = - typeof cliOptions.excludeDomains === 'string' - ? cliOptions.excludeDomains + const splitDomains = (value: unknown): string[] | undefined => + typeof value === 'string' + ? value .split(',') .map((d: string) => d.trim()) .filter((d: string) => d.length > 0) : undefined; + const includeDomains = splitDomains(cliOptions.includeDomains); + const excludeDomains = splitDomains(cliOptions.excludeDomains); const config: WebSearchTargetConfig = { targetType: 'webSearch', name: cliOptions.name!, gateway: cliOptions.gateway!, + ...(includeDomains && includeDomains.length > 0 ? { includeDomains } : {}), ...(excludeDomains && excludeDomains.length > 0 ? { excludeDomains } : {}), }; const result = await this.createWebSearchGatewayTarget(config); if (cliOptions.json) { console.log(JSON.stringify({ success: true, toolName: result.toolName })); } else { - const suffix = config.excludeDomains ? ` (excludeDomains=${config.excludeDomains.join(',')})` : ''; + const filters = [ + ...(config.includeDomains ? [`includeDomains=${config.includeDomains.join(',')}`] : []), + ...(config.excludeDomains ? [`excludeDomains=${config.excludeDomains.join(',')}`] : []), + ]; + const suffix = filters.length > 0 ? ` (${filters.join(', ')})` : ''; console.log(`Added web-search gateway target '${result.toolName}' on '${config.gateway}'${suffix}`); } return { ...telemetryAttrs, gateway_target_type: 'web-search' as const }; @@ -1226,7 +1241,7 @@ Target types and their options: const configurations = translateConnector({ connectorId: 'web-search', - input: { excludeDomains: config.excludeDomains }, + input: { includeDomains: config.includeDomains, excludeDomains: config.excludeDomains }, }); const target: AgentCoreGatewayTarget = { diff --git a/src/cli/primitives/__tests__/GatewayTargetPrimitive.test.ts b/src/cli/primitives/__tests__/GatewayTargetPrimitive.test.ts index 6cf352869..da677d099 100644 --- a/src/cli/primitives/__tests__/GatewayTargetPrimitive.test.ts +++ b/src/cli/primitives/__tests__/GatewayTargetPrimitive.test.ts @@ -357,6 +357,38 @@ describe('GatewayTargetPrimitive — createWebSearchGatewayTarget', () => { ]); }); + it('persists includeDomains in configurations when provided', async () => { + const { primitive, getProject } = makePrimitive(emptyProject()); + await primitive.createWebSearchGatewayTarget({ + targetType: 'webSearch', + name: 'ws', + gateway: 'main-gw', + includeDomains: ['docs.aws.amazon.com', 'aws.amazon.com'], + }); + const target = getProject().agentCoreGateways[0]?.targets[0]; + const wsConfig = (target?.configurations ?? []).find(c => c.name === 'WebSearch'); + expect((wsConfig?.parameterValues as any)?.domainFilter).toEqual({ + include: ['docs.aws.amazon.com', 'aws.amazon.com'], + }); + }); + + it('persists both domain lists in one domainFilter when both are given', async () => { + const { primitive, getProject } = makePrimitive(emptyProject()); + await primitive.createWebSearchGatewayTarget({ + targetType: 'webSearch', + name: 'ws', + gateway: 'main-gw', + includeDomains: ['aws.amazon.com'], + excludeDomains: ['internal.example.com'], + }); + const target = getProject().agentCoreGateways[0]?.targets[0]; + const wsConfig = (target?.configurations ?? []).find(c => c.name === 'WebSearch'); + expect((wsConfig?.parameterValues as any)?.domainFilter).toEqual({ + include: ['aws.amazon.com'], + exclude: ['internal.example.com'], + }); + }); + it('rejects a duplicate target name on the same gateway', async () => { const { primitive } = makePrimitive(emptyProject()); await primitive.createWebSearchGatewayTarget({ diff --git a/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx b/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx index 7721d0d33..67f160680 100644 --- a/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx +++ b/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx @@ -108,6 +108,7 @@ export function AddGatewayTargetScreen({ const isPassthroughEndpointStep = wizard.step === 'passthrough-endpoint'; const isPassthroughProtocolStep = wizard.step === 'passthrough-protocol'; const isPassthroughStickinessStep = wizard.step === 'passthrough-stickiness'; + const isIncludeDomainsStep = wizard.step === 'include-domains'; const isExcludeDomainsStep = wizard.step === 'exclude-domains'; const isConfirmStep = wizard.step === 'confirm'; const isAuthStep = isOutboundAuthStep || isApiGatewayAuthStep; @@ -425,6 +426,7 @@ export function AddGatewayTargetScreen({ targetType: 'webSearch', name: c.name, gateway: c.gateway!, + ...(c.includeDomains && c.includeDomains.length > 0 ? { includeDomains: c.includeDomains } : {}), ...(c.excludeDomains && c.excludeDomains.length > 0 ? { excludeDomains: c.excludeDomains } : {}), }); } else { @@ -812,6 +814,22 @@ export function AddGatewayTargetScreen({ /> )} + {isIncludeDomainsStep && ( + { + const domains = value + .split(',') + .map(d => d.trim()) + .filter(d => d.length > 0); + wizard.setIncludeDomains(domains.length > 0 ? domains : undefined); + }} + onCancel={() => wizard.goBack()} + /> + )} + {isExcludeDomainsStep && ( = { 'passthrough-stickiness': 'Stickiness', 'signing-service': 'Signing Service', 'signing-region': 'Signing Region', + 'include-domains': 'Include Domains', 'exclude-domains': 'Exclude Domains', confirm: 'Confirm', }; diff --git a/src/cli/tui/screens/mcp/useAddGatewayTargetWizard.ts b/src/cli/tui/screens/mcp/useAddGatewayTargetWizard.ts index 2d7af3f36..ff6841c10 100644 --- a/src/cli/tui/screens/mcp/useAddGatewayTargetWizard.ts +++ b/src/cli/tui/screens/mcp/useAddGatewayTargetWizard.ts @@ -58,7 +58,7 @@ export function useAddGatewayTargetWizard( break; case 'connector': if (config.connectorId === 'web-search') { - baseSteps.push('gateway', 'exclude-domains'); + baseSteps.push('gateway', 'include-domains', 'exclude-domains'); } else { baseSteps.push('kb-select', 'gateway'); } @@ -129,7 +129,7 @@ export function useAddGatewayTargetWizard( ...c, targetType, connectorId: resolvedConnectorId, - ...(resolvedConnectorId !== 'web-search' ? { excludeDomains: undefined } : {}), + ...(resolvedConnectorId !== 'web-search' ? { includeDomains: undefined, excludeDomains: undefined } : {}), })); switch (targetType) { case 'apiGateway': @@ -349,6 +349,21 @@ export function useAddGatewayTargetWizard( [goToNextStep] ); + /** + * Set the optional list of domains to restrict results to (web-search connector + * only) and advance. An empty submission clears the field. + */ + const setIncludeDomains = useCallback( + (includeDomains: string[] | undefined) => { + setConfig(c => ({ + ...c, + includeDomains: includeDomains && includeDomains.length > 0 ? includeDomains : undefined, + })); + goToNextStep(); + }, + [goToNextStep] + ); + /** * Set the optional list of domains to exclude (web-search connector only) * and advance to confirm. An empty submission clears the field. @@ -392,6 +407,7 @@ export function useAddGatewayTargetWizard( setStickinessConfig, setSigningService, setSigningRegion, + setIncludeDomains, setExcludeDomains, reset, };