diff --git a/docs/gateway.md b/docs/gateway.md index 09400ad20..5418674a7 100644 --- a/docs/gateway.md +++ b/docs/gateway.md @@ -50,7 +50,7 @@ agentcore add gateway-target \ --gateway my-gateway ``` -Supports outbound auth: `oauth` or `none`. +Supports outbound auth: `oauth`, `api-key`, or `none`. ### API Gateway REST API (`api-gateway`) @@ -152,7 +152,7 @@ Controls how the gateway authenticates with upstream targets. Configured per tar | --------- | ------------------------------ | --------------------------------------------- | | `none` | No outbound authentication | mcp-server, api-gateway | | `oauth` | OAuth2 client credentials flow | mcp-server, open-api-schema | -| `api-key` | API key passed to upstream | api-gateway, open-api-schema | +| `api-key` | API key passed to upstream | mcp-server, api-gateway, open-api-schema | | IAM role | Automatic IAM role auth | smithy-model, lambda-function-arn (exclusive) | #### OAuth Outbound Auth @@ -188,6 +188,27 @@ agentcore add gateway-target \ --credential-name MyOAuthProvider ``` +#### API Key Placement + +By default the gateway sends the API key as an `x-api-key` header. Customize placement with: + +```bash +agentcore add gateway-target \ + --type mcp-server \ + --name secure-tools \ + --endpoint https://api.example.com/mcp \ + --gateway my-gateway \ + --outbound-auth api-key \ + --credential-name MyApiKey \ + --api-key-location HEADER \ + --api-key-parameter-name Authorization \ + --api-key-prefix Bearer +``` + +This sends `Authorization: Bearer `. `--api-key-location` accepts `HEADER` or `QUERY_PARAMETER`. All three flags +are optional; omitting them keeps the `x-api-key` header default. MCP server, API Gateway, and OpenAPI schema targets +support API key outbound auth. + ## Adding a Gateway to an Existing Project If you already have agents and want to add gateway support, there are two approaches. diff --git a/integ-tests/add-remove-gateway.test.ts b/integ-tests/add-remove-gateway.test.ts index 8453c5e60..57fa8f254 100644 --- a/integ-tests/add-remove-gateway.test.ts +++ b/integ-tests/add-remove-gateway.test.ts @@ -564,3 +564,82 @@ describe('integration: schema-based target validation errors', () => { expect(result.exitCode).not.toBe(0); }); }); + +describe('integration: mcpServer target with API key placement', () => { + let project: TestProject; + const gatewayName = 'SecureMcpGateway'; + const targetName = 'secureTools'; + const credentialName = 'MyApiKey'; + + beforeAll(async () => { + project = await createTestProject({ noAgent: true }); + }); + + afterAll(async () => { + await project.cleanup(); + }); + + it('adds a gateway', async () => { + const result = await runCLI(['add', 'gateway', '--name', gatewayName, '--json'], project.projectPath); + + expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); + const json = JSON.parse(result.stdout); + expect(json.success).toBe(true); + }); + + it('adds an api-key credential', async () => { + const result = await runCLI( + ['add', 'credential', '--type', 'api-key', '--name', credentialName, '--api-key', 'secret123', '--json'], + project.projectPath + ); + expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); + }); + + it('adds an mcpServer target with custom placement', async () => { + const result = await runCLI( + [ + 'add', + 'gateway-target', + '--type', + 'mcp-server', + '--name', + targetName, + '--endpoint', + 'https://api.example.com/mcp', + '--gateway', + gatewayName, + '--outbound-auth', + 'api-key', + '--credential-name', + credentialName, + '--api-key-parameter-name', + 'Authorization', + '--api-key-prefix', + 'Bearer', + '--json', + ], + project.projectPath + ); + + expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); + const json = JSON.parse(result.stdout); + expect(json.success).toBe(true); + + const mcpSpec = await readProjectConfig(project.projectPath); + const target = mcpSpec.agentCoreGateways + ?.flatMap((g: { targets?: { name: string }[] }) => g.targets ?? []) + .find((t: { name: string }) => t.name === targetName); + expect(target, `Target "${targetName}" should be in gateway targets`).toBeTruthy(); + // HEADER is the default location so the helper omits it from the written config. + expect(target.outboundAuth).toEqual({ + type: 'API_KEY', + credentialName, + apiKey: { parameterName: 'Authorization', prefix: 'Bearer' }, + }); + }); + + it('validate passes', async () => { + const result = await runCLI(['validate', '--json'], project.projectPath); + expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); + }); +}); diff --git a/src/cli/aws/agentcore-control.ts b/src/cli/aws/agentcore-control.ts index f8d548b3e..5feada769 100644 --- a/src/cli/aws/agentcore-control.ts +++ b/src/cli/aws/agentcore-control.ts @@ -1004,7 +1004,12 @@ export interface GatewayTargetDetail { credentialProviderType: string; credentialProvider?: { oauthCredentialProvider?: { providerArn: string; scopes?: string[] }; - apiKeyCredentialProvider?: { providerArn: string }; + apiKeyCredentialProvider?: { + providerArn: string; + credentialLocation?: string; + credentialParameterName?: string; + credentialPrefix?: string; + }; }; }[]; } @@ -1146,7 +1151,12 @@ export async function getGatewayTargetDetail(options: { } : undefined, apiKeyCredentialProvider: c.credentialProvider.apiKeyCredentialProvider - ? { providerArn: c.credentialProvider.apiKeyCredentialProvider.providerArn ?? '' } + ? { + providerArn: c.credentialProvider.apiKeyCredentialProvider.providerArn ?? '', + credentialLocation: c.credentialProvider.apiKeyCredentialProvider.credentialLocation, + credentialParameterName: c.credentialProvider.apiKeyCredentialProvider.credentialParameterName, + credentialPrefix: c.credentialProvider.apiKeyCredentialProvider.credentialPrefix, + } : undefined, } : undefined, diff --git a/src/cli/commands/add/__tests__/validate.test.ts b/src/cli/commands/add/__tests__/validate.test.ts index 070801f40..6ab9a3058 100644 --- a/src/cli/commands/add/__tests__/validate.test.ts +++ b/src/cli/commands/add/__tests__/validate.test.ts @@ -1707,3 +1707,80 @@ describe('validateAddAgentOptions - session storage mount path', () => { expect(result.error).toBe('--session-storage-mount-path is not supported for TypeScript agents'); }); }); + +describe('validateAddGatewayTargetOptions — api key placement', () => { + beforeEach(() => { + mockReadProjectSpec.mockResolvedValue({ + agentCoreGateways: [{ name: 'gw' }], + credentials: [{ name: 'k', type: 'ApiKey' }], + }); + }); + + it('accepts api-key placement flags with api-key auth', async () => { + const r = await validateAddGatewayTargetOptions({ + name: 't', + type: 'mcp-server', + gateway: 'gw', + endpoint: 'https://e.com/mcp', + outboundAuthType: 'API_KEY', + credentialName: 'k', + apiKeyLocation: 'HEADER', + apiKeyParameterName: 'Authorization', + apiKeyPrefix: 'Bearer', + } as any); + expect(r.valid).toBe(true); + }); + + it('rejects api-key placement flags when auth type is not api-key', async () => { + const r = await validateAddGatewayTargetOptions({ + name: 't', + type: 'mcp-server', + gateway: 'gw', + endpoint: 'https://e.com/mcp', + outboundAuthType: 'OAUTH', + credentialName: 'k', + apiKeyLocation: 'HEADER', + } as any); + expect(r.valid).toBe(false); + }); + + it('rejects an invalid api-key location', async () => { + const r = await validateAddGatewayTargetOptions({ + name: 't', + type: 'mcp-server', + gateway: 'gw', + endpoint: 'https://e.com/mcp', + outboundAuthType: 'API_KEY', + credentialName: 'k', + apiKeyLocation: 'BODY', + } as any); + expect(r.valid).toBe(false); + }); + + it('accepts placement with the hyphenated --outbound-auth api-key form (mcpServer)', async () => { + const r = await validateAddGatewayTargetOptions({ + type: 'mcp-server', + name: 't', + gateway: 'gw', + endpoint: 'https://e.com/mcp', + outboundAuthType: 'api-key', + credentialName: 'k', + apiKeyParameterName: 'Authorization', + apiKeyPrefix: 'Bearer', + } as any); + expect(r.valid).toBe(true); + }); + + it('rejects api-key placement flags on lambda-function-arn type', async () => { + const r = await validateAddGatewayTargetOptions({ + type: 'lambda-function-arn', + name: 't', + gateway: 'gw', + lambdaArn: 'arn:aws:lambda:us-east-1:123456789012:function:f', + toolSchemaFile: 'tools.json', + apiKeyLocation: 'HEADER', + } as any); + expect(r.valid).toBe(false); + expect(r.error).toContain('lambda-function-arn'); + }); +}); diff --git a/src/cli/commands/add/types.ts b/src/cli/commands/add/types.ts index 6bb3b95b8..4e5895e67 100644 --- a/src/cli/commands/add/types.ts +++ b/src/cli/commands/add/types.ts @@ -89,6 +89,9 @@ export interface AddGatewayTargetOptions { toolFilterMethods?: string; schema?: string; schemaS3Account?: string; + apiKeyLocation?: string; + apiKeyParameterName?: string; + apiKeyPrefix?: string; json?: boolean; } diff --git a/src/cli/commands/add/validate.ts b/src/cli/commands/add/validate.ts index 8b509b6e5..55b04afa9 100644 --- a/src/cli/commands/add/validate.ts +++ b/src/cli/commands/add/validate.ts @@ -468,7 +468,7 @@ export async function validateAddGatewayTargetOptions(options: AddGatewayTargetO } if (options.outboundAuthType) { const apiGwAuth = TARGET_TYPE_AUTH_CONFIG.apiGateway; - const normalizedAuth = options.outboundAuthType.toUpperCase().replace('-', '_'); + const normalizedAuth = options.outboundAuthType.toUpperCase().replaceAll('-', '_'); if (!apiGwAuth.validAuthTypes.includes(normalizedAuth as 'OAUTH' | 'API_KEY' | 'NONE')) { return { valid: false, error: `${options.outboundAuthType} is not supported for api-gateway type` }; } @@ -485,6 +485,29 @@ export async function validateAddGatewayTargetOptions(options: AddGatewayTargetO if (options.toolSchemaFile) { return { valid: false, error: '--tool-schema-file is not applicable for api-gateway type' }; } + const hasPlacementApiGw = !!(options.apiKeyLocation ?? options.apiKeyParameterName ?? options.apiKeyPrefix); + if (hasPlacementApiGw) { + const apiGwNormalizedAuth = options.outboundAuthType?.toUpperCase().replaceAll('-', '_'); + if (apiGwNormalizedAuth !== 'API_KEY') { + return { + valid: false, + error: 'API key placement flags (--api-key-*) require --outbound-auth api-key', + }; + } + if ( + options.apiKeyLocation && + options.apiKeyLocation !== 'HEADER' && + options.apiKeyLocation !== 'QUERY_PARAMETER' + ) { + return { valid: false, error: '--api-key-location must be HEADER or QUERY_PARAMETER' }; + } + if (options.apiKeyParameterName && options.apiKeyParameterName.length > 64) { + return { valid: false, error: '--api-key-parameter-name must be 1-64 characters' }; + } + if (options.apiKeyPrefix && options.apiKeyPrefix.length > 64) { + return { valid: false, error: '--api-key-prefix must be 1-64 characters' }; + } + } options.language = 'Other'; return { valid: true }; } @@ -527,6 +550,12 @@ export async function validateAddGatewayTargetOptions(options: AddGatewayTargetO if (options.oauthClientId || options.oauthClientSecret || options.oauthDiscoveryUrl || options.oauthScopes) { return { valid: false, error: 'OAuth options are not applicable for lambda-function-arn type' }; } + if (options.apiKeyLocation || options.apiKeyParameterName || options.apiKeyPrefix) { + return { + valid: false, + error: 'API key placement flags (--api-key-*) are not applicable for lambda-function-arn type', + }; + } const configRoot = findConfigRoot(); const projectRoot = configRoot ? dirname(configRoot) : process.cwd(); @@ -611,6 +640,24 @@ export async function validateAddGatewayTargetOptions(options: AddGatewayTargetO } } + // API key placement validation (mcpServer and schema-based targets) + const hasPlacement = !!(options.apiKeyLocation ?? options.apiKeyParameterName ?? options.apiKeyPrefix); + if (hasPlacement) { + const normalizedPlacementAuth = options.outboundAuthType?.toUpperCase().replaceAll('-', '_'); + if (normalizedPlacementAuth !== 'API_KEY') { + return { valid: false, error: 'API key placement flags (--api-key-*) require --outbound-auth api-key' }; + } + if (options.apiKeyLocation && options.apiKeyLocation !== 'HEADER' && options.apiKeyLocation !== 'QUERY_PARAMETER') { + return { valid: false, error: '--api-key-location must be HEADER or QUERY_PARAMETER' }; + } + if (options.apiKeyParameterName && options.apiKeyParameterName.length > 64) { + return { valid: false, error: '--api-key-parameter-name must be 1-64 characters' }; + } + if (options.apiKeyPrefix && options.apiKeyPrefix.length > 64) { + return { valid: false, error: '--api-key-prefix must be 1-64 characters' }; + } + } + // Schema-based targets (OpenAPI / Smithy) if (mappedType === 'openApiSchema' || mappedType === 'smithyModel') { if (!options.schema) { diff --git a/src/cli/commands/import/__tests__/import-gateway.test.ts b/src/cli/commands/import/__tests__/import-gateway.test.ts index eb7c67dd6..411b4279c 100644 --- a/src/cli/commands/import/__tests__/import-gateway.test.ts +++ b/src/cli/commands/import/__tests__/import-gateway.test.ts @@ -123,6 +123,57 @@ describe('toGatewayTargetSpec — mcpServer targets', () => { }); }); + it('preserves custom API key placement on import', () => { + const providerArn = 'arn:aws:bedrock:us-east-1:123456789012:credential-provider/my-apikey'; + const detail = makeDetail({ + targetConfiguration: { mcp: { mcpServer: { endpoint: 'https://example.com/mcp' } } }, + credentialProviderConfigurations: [ + { + credentialProviderType: 'API_KEY', + credentialProvider: { + apiKeyCredentialProvider: { + providerArn, + credentialLocation: 'QUERY_PARAMETER', + credentialParameterName: 'api_key', + credentialPrefix: 'Token', + }, + }, + }, + ], + }); + const credentials = new Map([[providerArn, 'my-api-key-cred']]); + const result = toGatewayTargetSpec(detail, credentials, vi.fn()); + assert(result.success); + expect(result.target?.outboundAuth).toEqual({ + type: 'API_KEY', + credentialName: 'my-api-key-cred', + apiKey: { location: 'QUERY_PARAMETER', parameterName: 'api_key', prefix: 'Token' }, + }); + }); + + it('omits apiKey block when imported placement equals defaults', () => { + const providerArn = 'arn:aws:bedrock:us-east-1:123456789012:credential-provider/my-apikey'; + const detail = makeDetail({ + targetConfiguration: { mcp: { mcpServer: { endpoint: 'https://example.com/mcp' } } }, + credentialProviderConfigurations: [ + { + credentialProviderType: 'API_KEY', + credentialProvider: { + apiKeyCredentialProvider: { + providerArn, + credentialLocation: 'HEADER', + credentialParameterName: 'x-api-key', + }, + }, + }, + ], + }); + const credentials = new Map([[providerArn, 'my-api-key-cred']]); + const result = toGatewayTargetSpec(detail, credentials, vi.fn()); + assert(result.success); + expect(result.target?.outboundAuth).toEqual({ type: 'API_KEY', credentialName: 'my-api-key-cred' }); + }); + it('returns failure when OAuth credential not found in project', () => { const providerArn = 'arn:aws:bedrock:us-east-1:123456789012:credential-provider/missing-oauth'; const detail = makeDetail({ diff --git a/src/cli/commands/import/import-gateway.ts b/src/cli/commands/import/import-gateway.ts index d50f14fdd..45c70b031 100644 --- a/src/cli/commands/import/import-gateway.ts +++ b/src/cli/commands/import/import-gateway.ts @@ -21,6 +21,7 @@ import { } from '../../aws/agentcore-control'; import { ANSI } from '../../constants'; import { isAccessDeniedError } from '../../errors'; +import { buildApiKeyPlacement } from '../../primitives/api-key-placement'; import { withCommandRunTelemetry } from '../../telemetry/cli-command-run.js'; import { executeCdkImportPipeline } from './import-pipeline'; import { @@ -222,10 +223,15 @@ function resolveOutboundAuth( } if (config.credentialProviderType === 'API_KEY' && config.credentialProvider?.apiKeyCredentialProvider) { - const providerArn = config.credentialProvider.apiKeyCredentialProvider.providerArn; - const credentialName = credentials.get(providerArn); + const akp = config.credentialProvider.apiKeyCredentialProvider; + const credentialName = credentials.get(akp.providerArn); if (credentialName) { - return { success: true, auth: { type: 'API_KEY', credentialName } }; + const apiKey = buildApiKeyPlacement({ + location: akp.credentialLocation, + parameterName: akp.credentialParameterName, + prefix: akp.credentialPrefix, + }); + return { success: true, auth: { type: 'API_KEY', credentialName, ...(apiKey && { apiKey }) } }; } return failureResult( new ValidationError( diff --git a/src/cli/primitives/GatewayTargetPrimitive.ts b/src/cli/primitives/GatewayTargetPrimitive.ts index 6ef4bf598..cb240f9e2 100644 --- a/src/cli/primitives/GatewayTargetPrimitive.ts +++ b/src/cli/primitives/GatewayTargetPrimitive.ts @@ -15,6 +15,7 @@ import type { AgentCoreMcpSpec, AgentCoreProjectSpec, ApiGatewayHttpMethod, + ApiKeyOutboundConfig, DirectoryPath, FilePath, } from '../../schema'; @@ -42,6 +43,7 @@ import type { } from '../tui/screens/mcp/types'; import { DEFAULT_HANDLER, DEFAULT_NODE_VERSION, DEFAULT_PYTHON_VERSION } from '../tui/screens/mcp/types'; import { BasePrimitive } from './BasePrimitive'; +import { buildApiKeyPlacement } from './api-key-placement'; import { SOURCE_CODE_NOTE } from './constants'; import type { AddResult, AddScreenComponent } from './types'; import type { Command } from '@commander-js/extra-typings'; @@ -71,6 +73,45 @@ function extractMcpSpec(project: AgentCoreProjectSpec): AgentCoreMcpSpec { }; } +const CLI_OUTBOUND_AUTH_MAP: Record = { + oauth: 'OAUTH', + 'api-key': 'API_KEY', + api_key: 'API_KEY', + none: 'NONE', +}; + +/** + * Build the `outboundAuth` config fragment for a CLI-driven gateway target, + * including the optional API key placement block when auth resolves to API_KEY. + * Returns an empty object when no outbound auth type was provided so it can be + * spread directly into any target config. + */ +function buildOutboundAuthForCli(cliOptions: CLIAddGatewayTargetOptions): { + outboundAuth?: { + type: 'OAUTH' | 'API_KEY' | 'NONE'; + credentialName?: string; + apiKey?: ApiKeyOutboundConfig; + }; +} { + if (!cliOptions.outboundAuthType) return {}; + const resolvedType = CLI_OUTBOUND_AUTH_MAP[cliOptions.outboundAuthType.toLowerCase()] ?? 'NONE'; + const placement = + resolvedType === 'API_KEY' + ? buildApiKeyPlacement({ + location: cliOptions.apiKeyLocation, + parameterName: cliOptions.apiKeyParameterName, + prefix: cliOptions.apiKeyPrefix, + }) + : undefined; + return { + outboundAuth: { + type: resolvedType, + credentialName: cliOptions.credentialName, + ...(placement && { apiKey: placement }), + }, + }; +} + /** * GatewayTargetPrimitive handles all gateway target add/remove operations. * Absorbs logic from create-mcp.ts (tool) and remove-gateway-target.ts. @@ -291,6 +332,15 @@ export class GatewayTargetPrimitive extends BasePrimitive', 'OAuth scopes, comma-separated (for oauth auth) [non-interactive]') + .option( + '--api-key-location ', + 'API key location: HEADER or QUERY_PARAMETER (for api-key auth) [non-interactive]' + ) + .option( + '--api-key-parameter-name ', + 'API key header/query parameter name, e.g. Authorization (for api-key auth) [non-interactive]' + ) + .option('--api-key-prefix ', 'API key value prefix, e.g. Bearer (for api-key auth) [non-interactive]') .option('--rest-api-id ', 'REST API ID (for api-gateway type) [non-interactive]') .option('--stage ', 'Deployment stage (for api-gateway type) [non-interactive]') .option('--tool-filter-path ', 'Tool filter path pattern, e.g. /pets/* [non-interactive]') @@ -327,14 +377,6 @@ export class GatewayTargetPrimitive extends BasePrimitive = { - oauth: 'OAUTH', - 'api-key': 'API_KEY', - api_key: 'API_KEY', - none: 'NONE', - }; - const cliType = cliOptions.type ?? ''; const telemetryTargetType = GATEWAY_TARGET_TYPE_MAP[cliType] ?? ('unknown' as const); const telemetryOutboundAuth = standardize( @@ -366,16 +408,9 @@ export class GatewayTargetPrimitive extends BasePrimitive), }; const result = await this.createApiGatewayTarget(config); const output = { success: true, toolName: result.toolName }; @@ -404,14 +439,7 @@ export class GatewayTargetPrimitive extends BasePrimitive { + it('returns undefined when no fields are set', () => { + expect(buildApiKeyPlacement({})).toBeUndefined(); + }); + + it('returns undefined when fields equal the defaults (HEADER / x-api-key, no prefix)', () => { + expect(buildApiKeyPlacement({ location: 'HEADER', parameterName: 'x-api-key' })).toBeUndefined(); + }); + + it('builds a block when location differs', () => { + expect(buildApiKeyPlacement({ location: 'QUERY_PARAMETER' })).toEqual({ location: 'QUERY_PARAMETER' }); + }); + + it('builds a block with a custom parameter name and prefix', () => { + expect(buildApiKeyPlacement({ parameterName: 'Authorization', prefix: 'Bearer' })).toEqual({ + parameterName: 'Authorization', + prefix: 'Bearer', + }); + }); + + it('omits fields that are undefined', () => { + expect(buildApiKeyPlacement({ prefix: 'Bearer' })).toEqual({ prefix: 'Bearer' }); + }); + + it('omits a parameterName equal to the default but keeps a non-default location', () => { + expect(buildApiKeyPlacement({ location: 'QUERY_PARAMETER', parameterName: 'x-api-key' })).toEqual({ + location: 'QUERY_PARAMETER', + }); + }); +}); diff --git a/src/cli/primitives/api-key-placement.ts b/src/cli/primitives/api-key-placement.ts new file mode 100644 index 000000000..7b9ae24cf --- /dev/null +++ b/src/cli/primitives/api-key-placement.ts @@ -0,0 +1,31 @@ +import type { ApiKeyOutboundConfig } from '../../schema'; + +/** Raw placement inputs (from CLI flags or an imported AWS response). */ +export interface ApiKeyPlacementInputs { + location?: string; + parameterName?: string; + prefix?: string; +} + +const DEFAULT_LOCATION = 'HEADER'; +const DEFAULT_PARAMETER_NAME = 'x-api-key'; + +/** + * Build the optional `apiKey` placement block for an API_KEY outbound auth. + * Returns undefined when the inputs are empty or equal the CDK defaults + * (HEADER / x-api-key / no prefix), so configs that don't customize placement + * stay free of an apiKey block. + */ +export function buildApiKeyPlacement(inputs: ApiKeyPlacementInputs): ApiKeyOutboundConfig | undefined { + const block: ApiKeyOutboundConfig = {}; + if (inputs.location && inputs.location !== DEFAULT_LOCATION) { + block.location = inputs.location as ApiKeyOutboundConfig['location']; + } + if (inputs.parameterName && inputs.parameterName !== DEFAULT_PARAMETER_NAME) { + block.parameterName = inputs.parameterName; + } + if (inputs.prefix) { + block.prefix = inputs.prefix; + } + return Object.keys(block).length > 0 ? block : undefined; +} diff --git a/src/cli/tui/components/api-key-placement/ApiKeyPlacementInput.tsx b/src/cli/tui/components/api-key-placement/ApiKeyPlacementInput.tsx new file mode 100644 index 000000000..e99598cc6 --- /dev/null +++ b/src/cli/tui/components/api-key-placement/ApiKeyPlacementInput.tsx @@ -0,0 +1,124 @@ +import type { ApiKeyOutboundConfig } from '../../../../schema'; +import { useListNavigation, useMultiSelectNavigation } from '../../hooks'; +import { TextInput, WizardMultiSelect, WizardSelect } from '../index'; +import type { SelectableItem } from '../index'; +import type { ApiKeyPlacementSubStep } from './types'; +import { PLACEMENT_OPTIONS } from './types'; +import { Box } from 'ink'; +import React, { useRef, useState } from 'react'; + +export interface ApiKeyPlacementInputProps { + /** Called with the resolved placement (undefined = all defaults / skipped). */ + onComplete: (placement: ApiKeyOutboundConfig | undefined) => void; + onBack: () => void; +} + +const LOCATION_ITEMS: SelectableItem[] = [ + { id: 'HEADER', title: 'HEADER', description: 'Send the key in an HTTP header' }, + { id: 'QUERY_PARAMETER', title: 'QUERY_PARAMETER', description: 'Send the key as a query parameter' }, +]; + +const CHECKLIST_ITEMS: SelectableItem[] = PLACEMENT_OPTIONS.map(o => ({ id: o.id, title: o.title })); + +export function ApiKeyPlacementInput({ onComplete, onBack }: ApiKeyPlacementInputProps) { + const [subStep, setSubStep] = useState('checklist'); + const [pending, setPending] = useState([]); + const resolved = useRef({}); + + const finish = () => { + const result = Object.keys(resolved.current).length > 0 ? { ...resolved.current } : undefined; + onComplete(result); + }; + + const advance = (queue: ApiKeyPlacementSubStep[]) => { + if (queue.length > 0) { + const [next, ...rest] = queue; + setPending(rest); + setSubStep(next!); + } else { + finish(); + } + }; + + const checklistNav = useMultiSelectNavigation({ + items: CHECKLIST_ITEMS, + getId: (item: SelectableItem) => item.id, + onConfirm: (selectedIds: string[]) => { + const queue: ApiKeyPlacementSubStep[] = []; + if (selectedIds.includes('location')) queue.push('location'); + if (selectedIds.includes('parameterName')) queue.push('parameterName'); + if (selectedIds.includes('prefix')) queue.push('prefix'); + advance(queue); + }, + onExit: onBack, + isActive: subStep === 'checklist', + requireSelection: false, + }); + + const locationNav = useListNavigation({ + items: LOCATION_ITEMS, + onSelect: (item: SelectableItem) => { + if (item.id !== 'HEADER') { + resolved.current.location = item.id as ApiKeyOutboundConfig['location']; + } + advance(pending); + }, + onExit: () => setSubStep('checklist'), + isActive: subStep === 'location', + }); + + if (subStep === 'checklist') { + return ( + + + + ); + } + + if (subStep === 'location') { + return ( + + ); + } + + if (subStep === 'parameterName') { + return ( + { + const v = value.trim(); + if (v && v !== 'x-api-key') resolved.current.parameterName = v; + advance(pending); + }} + onCancel={() => setSubStep('checklist')} + /> + ); + } + + return ( + { + const v = value.trim(); + if (v) resolved.current.prefix = v; + advance(pending); + }} + onCancel={() => setSubStep('checklist')} + allowEmpty + /> + ); +} diff --git a/src/cli/tui/components/api-key-placement/__tests__/ApiKeyPlacementInput.test.tsx b/src/cli/tui/components/api-key-placement/__tests__/ApiKeyPlacementInput.test.tsx new file mode 100644 index 000000000..73853dc07 --- /dev/null +++ b/src/cli/tui/components/api-key-placement/__tests__/ApiKeyPlacementInput.test.tsx @@ -0,0 +1,46 @@ +import { ApiKeyPlacementInput } from '../ApiKeyPlacementInput'; +import { render } from 'ink-testing-library'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +const DOWN_ARROW = '\x1B[B'; +const ENTER = '\r'; +const SPACE = ' '; + +const tick = () => new Promise(r => setTimeout(r, 20)); + +describe('ApiKeyPlacementInput', () => { + it('renders the placement checklist with default labels', () => { + const { lastFrame } = render(); + expect(lastFrame()).toContain('Location (default: HEADER)'); + expect(lastFrame()).toContain('Parameter name (default: x-api-key)'); + }); + + it('completes with undefined placement when nothing is selected (skip path)', async () => { + const onComplete = vi.fn(); + const { stdin } = render(); + await tick(); + stdin.write(ENTER); // Enter with no selection + await tick(); + expect(onComplete).toHaveBeenCalledWith(undefined); + }); + + it('builds a placement block with a custom prefix', async () => { + const onComplete = vi.fn(); + const { stdin } = render(); + await tick(); + stdin.write(DOWN_ARROW); // cursor: location -> parameterName + await tick(); + stdin.write(DOWN_ARROW); // cursor: parameterName -> prefix + await tick(); + stdin.write(SPACE); // toggle 'prefix' on + await tick(); + stdin.write(ENTER); // confirm checklist -> enter prefix sub-step + await tick(); + stdin.write('Bearer'); // type the prefix value + await tick(); + stdin.write(ENTER); // submit TextInput + await tick(); + expect(onComplete).toHaveBeenCalledWith({ prefix: 'Bearer' }); + }); +}); diff --git a/src/cli/tui/components/api-key-placement/index.ts b/src/cli/tui/components/api-key-placement/index.ts new file mode 100644 index 000000000..af4f15905 --- /dev/null +++ b/src/cli/tui/components/api-key-placement/index.ts @@ -0,0 +1,2 @@ +export { ApiKeyPlacementInput } from './ApiKeyPlacementInput'; +export type { ApiKeyPlacementInputProps } from './ApiKeyPlacementInput'; diff --git a/src/cli/tui/components/api-key-placement/types.ts b/src/cli/tui/components/api-key-placement/types.ts new file mode 100644 index 000000000..76b357906 --- /dev/null +++ b/src/cli/tui/components/api-key-placement/types.ts @@ -0,0 +1,7 @@ +export type ApiKeyPlacementSubStep = 'checklist' | 'location' | 'parameterName' | 'prefix'; + +export const PLACEMENT_OPTIONS = [ + { id: 'location', title: 'Location (default: HEADER)' }, + { id: 'parameterName', title: 'Parameter name (default: x-api-key)' }, + { id: 'prefix', title: 'Prefix (default: none)' }, +] as const; diff --git a/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx b/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx index fa022b4f7..848c638f5 100644 --- a/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx +++ b/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx @@ -1,7 +1,8 @@ -import type { ApiGatewayHttpMethod, GatewayTargetType } from '../../../../schema'; +import type { ApiGatewayHttpMethod, ApiKeyOutboundConfig, GatewayTargetType } from '../../../../schema'; import { ToolNameSchema } from '../../../../schema'; import { ConfirmReview, Panel, Screen, StepIndicator, TextInput, WizardSelect } from '../../components'; import type { SelectableItem } from '../../components'; +import { ApiKeyPlacementInput } from '../../components/api-key-placement'; import { HELP_TEXT } from '../../constants'; import { useListNavigation } from '../../hooks'; import { generateUniqueName } from '../../utils'; @@ -67,6 +68,13 @@ export function AddGatewayTargetScreen({ const [pendingCredType, setPendingCredType] = useState<'OAUTH' | 'API_KEY' | null>(null); const [filterPath, setFilterPathLocal] = useState(null); + // When an API_KEY credential is chosen, capture it here and mount the placement + // sub-form before finalizing the auth selection. null = no placement step active. + const [awaitingApiKeyPlacement, setAwaitingApiKeyPlacement] = useState<{ + type: 'API_KEY'; + credentialName: string; + } | null>(null); + // ── Step flags ── const isGatewayStep = wizard.step === 'gateway'; const isOutboundAuthStep = wizard.step === 'outbound-auth'; @@ -117,7 +125,7 @@ export function AddGatewayTargetScreen({ // ── Auth completion callbacks ── // Shared handler that routes to the correct wizard setter based on the active step. const completeAuth = useCallback( - (auth?: { type: 'OAUTH' | 'API_KEY' | 'NONE'; credentialName?: string }) => { + (auth?: { type: 'OAUTH' | 'API_KEY' | 'NONE'; credentialName?: string; apiKey?: ApiKeyOutboundConfig }) => { if (isApiGatewayAuthStep) { wizard.setApiGatewayAuth(auth as ApiGatewayTargetConfig['outboundAuth']); } else { @@ -208,11 +216,11 @@ export function AddGatewayTargetScreen({ if (item.id === 'create-new') { onCreateCredential({ ...wizard.config, outboundAuth: { type: 'API_KEY' } }); } else { - completeAuth({ type: 'API_KEY', credentialName: item.id }); + setAwaitingApiKeyPlacement({ type: 'API_KEY', credentialName: item.id }); } }, onExit: () => setPendingCredType(null), - isActive: isAuthStep && pendingCredType === 'API_KEY', + isActive: isAuthStep && pendingCredType === 'API_KEY' && !awaitingApiKeyPlacement, }); // Confirm step @@ -266,17 +274,19 @@ export function AddGatewayTargetScreen({ }); // ── Render ── - const helpText = isConfirmStep - ? HELP_TEXT.CONFIRM_CANCEL - : isTextStep || - isRestApiIdStep || - isStageStep || - isToolFiltersStep || - isSchemaSourceStep || - isLambdaArnStep || - isToolSchemaStep - ? HELP_TEXT.TEXT_INPUT - : HELP_TEXT.NAVIGATE_SELECT; + const helpText = awaitingApiKeyPlacement + ? HELP_TEXT.MULTI_SELECT + : isConfirmStep + ? HELP_TEXT.CONFIRM_CANCEL + : isTextStep || + isRestApiIdStep || + isStageStep || + isToolFiltersStep || + isSchemaSourceStep || + isLambdaArnStep || + isToolSchemaStep + ? HELP_TEXT.TEXT_INPUT + : HELP_TEXT.NAVIGATE_SELECT; const headerContent = ; @@ -333,7 +343,7 @@ export function AddGatewayTargetScreen({ /> )} - {isAuthStep && pendingCredType === 'API_KEY' && ( + {isAuthStep && pendingCredType === 'API_KEY' && !awaitingApiKeyPlacement && ( )} + {/* API key placement sub-form — mounted after a credential is selected. */} + {isAuthStep && awaitingApiKeyPlacement && ( + { + completeAuth({ + type: 'API_KEY', + credentialName: awaitingApiKeyPlacement.credentialName, + ...(placement && { apiKey: placement }), + }); + setAwaitingApiKeyPlacement(null); + }} + onBack={() => setAwaitingApiKeyPlacement(null)} + /> + )} + {isTextStep && ( new Promise(r => setTimeout(r, 20)); + +const DOWN_ARROW = '\x1B[B'; +const ENTER = '\r'; +const SPACE = ' '; + +const baseConfig: GatewayTargetWizardState = { + name: 'secure-tools', + targetType: 'mcpServer', + endpoint: 'https://example.com/mcp', + gateway: 'my-gateway', + toolDefinition: { name: 'secure-tools', description: 'Tool', inputSchema: { type: 'object' } }, +}; + +function renderScreen(onComplete: (config: AddGatewayTargetConfig) => void) { + return render( + + ); +} + +describe('AddGatewayTargetScreen — API key placement', () => { + it('routes API_KEY credential selection through the placement sub-form before completing', async () => { + const onComplete = vi.fn<(config: AddGatewayTargetConfig) => void>(); + const { stdin, lastFrame } = renderScreen(onComplete); + + await tick(); + // Auth type options: OAuth, API Key, No authorization. Move to API Key and select it. + stdin.write(DOWN_ARROW); // OAuth → API Key + await tick(); + stdin.write(ENTER); // select API Key auth type + await tick(); + // Credential picker: select the existing api-key credential (first item). + stdin.write(ENTER); + await tick(); + + // Placement sub-form should now be mounted, not yet completed. + expect(lastFrame()).toContain('API key placement'); + expect(onComplete).not.toHaveBeenCalled(); + + // Skip placement (keep defaults) — Enter with no checklist selection. + stdin.write(ENTER); + await tick(); + + // Now on the confirm step — confirm to finalize. + stdin.write(ENTER); + await tick(); + + expect(onComplete).toHaveBeenCalledTimes(1); + const config = onComplete.mock.calls[0]![0]; + expect(config.targetType).toBe('mcpServer'); + expect((config as McpServerTargetConfig).outboundAuth).toEqual({ + type: 'API_KEY', + credentialName: 'my-api-key', + }); + }); + + it('threads a custom placement onto the completed config', async () => { + const onComplete = vi.fn<(config: AddGatewayTargetConfig) => void>(); + const { stdin } = renderScreen(onComplete); + + await tick(); + stdin.write(DOWN_ARROW); // OAuth → API Key + await tick(); + stdin.write(ENTER); // select API Key auth type + await tick(); + stdin.write(ENTER); // select credential + await tick(); + + // Placement checklist order: location, parameterName, prefix. Toggle "prefix". + stdin.write(DOWN_ARROW); // → parameterName + await tick(); + stdin.write(DOWN_ARROW); // → prefix + await tick(); + stdin.write(SPACE); // toggle prefix + await tick(); + stdin.write(ENTER); // continue → prefix sub-step + await tick(); + stdin.write('Bearer'); + await tick(); + stdin.write(ENTER); // submit prefix + await tick(); + + // Now on the confirm step — confirm to finalize. + stdin.write(ENTER); + await tick(); + + expect(onComplete).toHaveBeenCalledTimes(1); + const config = onComplete.mock.calls[0]![0]; + expect(config.targetType).toBe('mcpServer'); + expect((config as McpServerTargetConfig).outboundAuth).toEqual({ + type: 'API_KEY', + credentialName: 'my-api-key', + apiKey: { prefix: 'Bearer' }, + }); + }); +}); diff --git a/src/cli/tui/screens/mcp/types.ts b/src/cli/tui/screens/mcp/types.ts index 59be5abe7..adcb74f8b 100644 --- a/src/cli/tui/screens/mcp/types.ts +++ b/src/cli/tui/screens/mcp/types.ts @@ -1,5 +1,6 @@ import type { ApiGatewayHttpMethod, + ApiKeyOutboundConfig, CustomClaimValidation, GatewayAuthorizerType, GatewayExceptionLevel, @@ -116,6 +117,7 @@ export interface GatewayTargetWizardState { type: 'OAUTH' | 'API_KEY' | 'NONE'; credentialName?: string; scopes?: string[]; + apiKey?: ApiKeyOutboundConfig; }; restApiId?: string; stage?: string; @@ -142,6 +144,7 @@ export interface McpServerTargetConfig { type: 'OAUTH' | 'API_KEY' | 'NONE'; credentialName?: string; scopes?: string[]; + apiKey?: ApiKeyOutboundConfig; }; } @@ -155,6 +158,7 @@ export interface ApiGatewayTargetConfig { outboundAuth?: { type: 'API_KEY' | 'NONE'; credentialName?: string; + apiKey?: ApiKeyOutboundConfig; }; } @@ -167,6 +171,7 @@ export interface SchemaBasedTargetConfig { type: 'OAUTH' | 'API_KEY' | 'NONE'; credentialName?: string; scopes?: string[]; + apiKey?: ApiKeyOutboundConfig; }; } diff --git a/src/cli/tui/screens/mcp/useAddGatewayTargetWizard.ts b/src/cli/tui/screens/mcp/useAddGatewayTargetWizard.ts index bfef7d4ac..daf2ced3a 100644 --- a/src/cli/tui/screens/mcp/useAddGatewayTargetWizard.ts +++ b/src/cli/tui/screens/mcp/useAddGatewayTargetWizard.ts @@ -1,5 +1,11 @@ import { APP_DIR, MCP_APP_SUBDIR } from '../../../../lib'; -import type { ApiGatewayHttpMethod, GatewayTargetType, SchemaSource, ToolDefinition } from '../../../../schema'; +import type { + ApiGatewayHttpMethod, + ApiKeyOutboundConfig, + GatewayTargetType, + SchemaSource, + ToolDefinition, +} from '../../../../schema'; import type { AddGatewayTargetStep, GatewayTargetWizardState } from './types'; import { useCallback, useMemo, useState } from 'react'; @@ -137,7 +143,7 @@ export function useAddGatewayTargetWizard( ); const setOutboundAuth = useCallback( - (outboundAuth: { type: 'OAUTH' | 'API_KEY' | 'NONE'; credentialName?: string }) => { + (outboundAuth: { type: 'OAUTH' | 'API_KEY' | 'NONE'; credentialName?: string; apiKey?: ApiKeyOutboundConfig }) => { setConfig(c => ({ ...c, outboundAuth, @@ -177,7 +183,7 @@ export function useAddGatewayTargetWizard( ); const setApiGatewayAuth = useCallback( - (outboundAuth?: { type: 'API_KEY' | 'NONE'; credentialName?: string }) => { + (outboundAuth?: { type: 'API_KEY' | 'NONE'; credentialName?: string; apiKey?: ApiKeyOutboundConfig }) => { setConfig(c => ({ ...c, outboundAuth })); goToNextStep(); }, diff --git a/src/schema/llm-compacted/mcp.ts b/src/schema/llm-compacted/mcp.ts index 9e1e8d8a2..2edcb8a07 100644 --- a/src/schema/llm-compacted/mcp.ts +++ b/src/schema/llm-compacted/mcp.ts @@ -47,6 +47,8 @@ interface OutboundAuth { type: 'OAUTH' | 'API_KEY' | 'NONE'; credentialName?: string; scopes?: string[]; + /** API key placement (only when type === 'API_KEY'). Optional; CDK defaults to HEADER / x-api-key. */ + apiKey?: { location?: 'HEADER' | 'QUERY_PARAMETER'; parameterName?: string; prefix?: string }; } interface ApiGatewayConfig { diff --git a/src/schema/schemas/__tests__/mcp.test.ts b/src/schema/schemas/__tests__/mcp.test.ts index 0ab33c2a4..a9a72651f 100644 --- a/src/schema/schemas/__tests__/mcp.test.ts +++ b/src/schema/schemas/__tests__/mcp.test.ts @@ -9,6 +9,7 @@ import { AgentCoreGatewayTargetSchema, AgentCoreMcpRuntimeToolSchema, ApiGatewayConfigSchema, + ApiKeyOutboundConfigSchema, GatewayExceptionLevelSchema, GatewayTargetTypeSchema, LambdaFunctionArnConfigSchema, @@ -1055,3 +1056,81 @@ describe('CustomClaimValidationSchema', () => { expect(result.success).toBe(false); }); }); + +describe('OutboundAuth apiKey placement', () => { + it('allows mcpServer + API_KEY (previously rejected)', () => { + const r = AgentCoreGatewayTargetSchema.safeParse({ + name: 'myTarget', + targetType: 'mcpServer', + endpoint: 'https://example.com/mcp', + outboundAuth: { type: 'API_KEY', credentialName: 'my-key' }, + }); + expect(r.success).toBe(true); + }); + + it('allows custom placement on API_KEY', () => { + const r = AgentCoreGatewayTargetSchema.safeParse({ + name: 'myTarget', + targetType: 'mcpServer', + endpoint: 'https://example.com/mcp', + outboundAuth: { + type: 'API_KEY', + credentialName: 'my-key', + apiKey: { location: 'HEADER', parameterName: 'Authorization', prefix: 'Bearer' }, + }, + }); + expect(r.success).toBe(true); + }); + + it('rejects apiKey placement on a non-API_KEY auth type', () => { + const r = AgentCoreGatewayTargetSchema.safeParse({ + name: 'myTarget', + targetType: 'mcpServer', + endpoint: 'https://example.com/mcp', + outboundAuth: { type: 'OAUTH', credentialName: 'oauth', apiKey: { location: 'HEADER' } }, + }); + expect(r.success).toBe(false); + if (!r.success) { + const issue = r.error.issues.find(i => i.path.join('.') === 'outboundAuth.apiKey'); + expect(issue?.message).toBe('apiKey placement is only valid when outboundAuth.type is API_KEY'); + } + }); + + it('still validates an old API_KEY target with no apiKey block (backwards compat)', () => { + const r = AgentCoreGatewayTargetSchema.safeParse({ + name: 'myTarget', + targetType: 'openApiSchema', + schemaSource: { inline: { path: 'spec.json' } }, + outboundAuth: { type: 'API_KEY', credentialName: 'my-key' }, + }); + expect(r.success).toBe(true); + }); +}); + +describe('ApiKeyOutboundConfigSchema', () => { + it('accepts a full placement block', () => { + expect( + ApiKeyOutboundConfigSchema.safeParse({ location: 'HEADER', parameterName: 'Authorization', prefix: 'Bearer' }) + .success + ).toBe(true); + }); + it('accepts an empty object (all optional)', () => { + expect(ApiKeyOutboundConfigSchema.safeParse({}).success).toBe(true); + }); + it('rejects an unknown location', () => { + expect(ApiKeyOutboundConfigSchema.safeParse({ location: 'BODY' }).success).toBe(false); + }); + it('rejects unknown keys (strict)', () => { + expect(ApiKeyOutboundConfigSchema.safeParse({ foo: 'bar' }).success).toBe(false); + }); + it('rejects parameterName over 64 chars', () => { + expect(ApiKeyOutboundConfigSchema.safeParse({ parameterName: 'x'.repeat(65) }).success).toBe(false); + }); + it('rejects prefix over 64 chars', () => { + expect(ApiKeyOutboundConfigSchema.safeParse({ prefix: 'x'.repeat(65) }).success).toBe(false); + }); + it('rejects empty parameterName and empty prefix', () => { + expect(ApiKeyOutboundConfigSchema.safeParse({ parameterName: '' }).success).toBe(false); + expect(ApiKeyOutboundConfigSchema.safeParse({ prefix: '' }).success).toBe(false); + }); +}); diff --git a/src/schema/schemas/mcp.ts b/src/schema/schemas/mcp.ts index 42cd89810..ddf617e9d 100644 --- a/src/schema/schemas/mcp.ts +++ b/src/schema/schemas/mcp.ts @@ -29,11 +29,27 @@ export type GatewayTargetType = z.infer; export const OutboundAuthTypeSchema = z.enum(['OAUTH', 'API_KEY', 'NONE']); export type OutboundAuthType = z.infer; +/** + * API key placement on the outbound request (maps to CFN + * CfnGatewayTarget.apiKeyCredentialProvider). All fields optional; the CDK + * applies defaults (HEADER / x-api-key) when absent so existing targets are + * unchanged. Only meaningful when OutboundAuth.type is 'API_KEY'. + */ +export const ApiKeyOutboundConfigSchema = z + .object({ + location: z.enum(['HEADER', 'QUERY_PARAMETER']).optional(), + parameterName: z.string().min(1).max(64).optional(), + prefix: z.string().min(1).max(64).optional(), + }) + .strict(); +export type ApiKeyOutboundConfig = z.infer; + export const OutboundAuthSchema = z .object({ type: OutboundAuthTypeSchema.default('NONE'), credentialName: z.string().min(1).optional(), scopes: z.array(z.string()).optional(), + apiKey: ApiKeyOutboundConfigSchema.optional(), }) .strict(); @@ -57,7 +73,7 @@ export const TARGET_TYPE_AUTH_CONFIG: Record< openApiSchema: { authRequired: true, validAuthTypes: ['OAUTH', 'API_KEY'], iamRoleFallback: false }, smithyModel: { authRequired: false, validAuthTypes: [], iamRoleFallback: true }, apiGateway: { authRequired: false, validAuthTypes: ['API_KEY', 'NONE'], iamRoleFallback: true }, - mcpServer: { authRequired: false, validAuthTypes: ['OAUTH', 'NONE'], iamRoleFallback: false }, + mcpServer: { authRequired: false, validAuthTypes: ['OAUTH', 'API_KEY', 'NONE'], iamRoleFallback: false }, lambda: { authRequired: false, validAuthTypes: ['OAUTH', 'NONE'], iamRoleFallback: true }, lambdaFunctionArn: { authRequired: false, validAuthTypes: ['OAUTH', 'NONE'], iamRoleFallback: true }, }; @@ -543,6 +559,13 @@ export const AgentCoreGatewayTargetSchema = z path: ['outboundAuth', 'credentialName'], }); } + if (data.outboundAuth?.apiKey && data.outboundAuth.type !== 'API_KEY') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'apiKey placement is only valid when outboundAuth.type is API_KEY', + path: ['outboundAuth', 'apiKey'], + }); + } }); export type AgentCoreGatewayTarget = z.infer;