From a029c54dd58b37c6e1a1fedc2fd4fc53a8ac7c65 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 10 Jun 2026 19:47:14 +0000 Subject: [PATCH 01/14] feat(schema): add apiKey placement + mcpServer API_KEY (lockstep with CDK) --- src/schema/llm-compacted/mcp.ts | 2 ++ src/schema/schemas/__tests__/mcp.test.ts | 46 ++++++++++++++++++++++++ src/schema/schemas/mcp.ts | 25 ++++++++++++- 3 files changed, 72 insertions(+), 1 deletion(-) 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..acfbc7ab5 100644 --- a/src/schema/schemas/__tests__/mcp.test.ts +++ b/src/schema/schemas/__tests__/mcp.test.ts @@ -1055,3 +1055,49 @@ 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); + }); + + 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); + }); +}); 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; From 8843a4b1a6438cf608269526324b49cc9c53ab1a Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 10 Jun 2026 19:51:04 +0000 Subject: [PATCH 02/14] test(schema): strengthen apiKey guard assertion + add direct unit tests --- src/schema/schemas/__tests__/mcp.test.ts | 33 ++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/schema/schemas/__tests__/mcp.test.ts b/src/schema/schemas/__tests__/mcp.test.ts index acfbc7ab5..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, @@ -1089,6 +1090,10 @@ describe('OutboundAuth apiKey placement', () => { 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)', () => { @@ -1101,3 +1106,31 @@ describe('OutboundAuth apiKey placement', () => { 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); + }); +}); From 59b9a9c74bfdf86e20e73956ae6531fc5f6786f3 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 10 Jun 2026 19:50:41 +0000 Subject: [PATCH 03/14] chore(schema): regenerate JSON schema for apiKey placement --- schemas/agentcore.schema.v1.json | 40 ++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/schemas/agentcore.schema.v1.json b/schemas/agentcore.schema.v1.json index 36e417528..6f093988c 100644 --- a/schemas/agentcore.schema.v1.json +++ b/schemas/agentcore.schema.v1.json @@ -1087,6 +1087,26 @@ "items": { "type": "string" } + }, + "apiKey": { + "type": "object", + "properties": { + "location": { + "type": "string", + "enum": ["HEADER", "QUERY_PARAMETER"] + }, + "parameterName": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "prefix": { + "type": "string", + "minLength": 1, + "maxLength": 64 + } + }, + "additionalProperties": false } }, "additionalProperties": false @@ -1745,6 +1765,26 @@ "items": { "type": "string" } + }, + "apiKey": { + "type": "object", + "properties": { + "location": { + "type": "string", + "enum": ["HEADER", "QUERY_PARAMETER"] + }, + "parameterName": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "prefix": { + "type": "string", + "minLength": 1, + "maxLength": 64 + } + }, + "additionalProperties": false } }, "additionalProperties": false From 19f9a86141bdc9e3768b0f1910f308a909daaa1d Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 10 Jun 2026 19:52:28 +0000 Subject: [PATCH 04/14] feat(cli): add shared buildApiKeyPlacement helper Converts raw placement inputs (location/parameterName/prefix) into the optional ApiKeyOutboundConfig block, returning undefined when values match the CDK defaults (HEADER / x-api-key / no prefix) to avoid writing redundant config blocks. --- .../__tests__/api-key-placement.test.ts | 33 +++++++++++++++++++ src/cli/primitives/api-key-placement.ts | 31 +++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 src/cli/primitives/__tests__/api-key-placement.test.ts create mode 100644 src/cli/primitives/api-key-placement.ts diff --git a/src/cli/primitives/__tests__/api-key-placement.test.ts b/src/cli/primitives/__tests__/api-key-placement.test.ts new file mode 100644 index 000000000..14f920409 --- /dev/null +++ b/src/cli/primitives/__tests__/api-key-placement.test.ts @@ -0,0 +1,33 @@ +import { buildApiKeyPlacement } from '../api-key-placement'; +import { describe, expect, it } from 'vitest'; + +describe('buildApiKeyPlacement', () => { + 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; +} From b856206da58393715cecdb1dfd448a41fb8c9821 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 10 Jun 2026 20:00:41 +0000 Subject: [PATCH 05/14] feat(cli): add --api-key-location/-parameter-name/-prefix to add gateway-target Registers three new optional CLI flags on `add gateway-target` for API key placement customisation (HEADER vs QUERY_PARAMETER, parameter name, and value prefix). Validation rejects placement flags when outbound auth type is not API_KEY and enforces valid location values and length constraints on name/prefix. Placement is threaded into the outboundAuth block for the mcpServer, schema-based (openApiSchema/ smithyModel), and apiGateway branches via buildApiKeyPlacement. Also widens the TUI config types (McpServerTargetConfig, ApiGatewayTargetConfig, SchemaBasedTargetConfig, GatewayTargetWizardState) to carry the optional apiKey placement field, unblocking downstream TUI tasks. --- .../commands/add/__tests__/validate.test.ts | 50 +++++++++++ src/cli/commands/add/types.ts | 3 + src/cli/commands/add/validate.ts | 46 ++++++++++ src/cli/primitives/GatewayTargetPrimitive.ts | 86 ++++++++++++++----- src/cli/tui/screens/mcp/types.ts | 5 ++ 5 files changed, 170 insertions(+), 20 deletions(-) diff --git a/src/cli/commands/add/__tests__/validate.test.ts b/src/cli/commands/add/__tests__/validate.test.ts index 070801f40..965f034d3 100644 --- a/src/cli/commands/add/__tests__/validate.test.ts +++ b/src/cli/commands/add/__tests__/validate.test.ts @@ -1707,3 +1707,53 @@ 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); + }); +}); 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..d47a76619 100644 --- a/src/cli/commands/add/validate.ts +++ b/src/cli/commands/add/validate.ts @@ -485,6 +485,32 @@ 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().replace('-', '_'); + 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 < 1 || options.apiKeyParameterName.length > 64) + ) { + return { valid: false, error: '--api-key-parameter-name must be 1-64 characters' }; + } + if (options.apiKeyPrefix && (options.apiKeyPrefix.length < 1 || options.apiKeyPrefix.length > 64)) { + return { valid: false, error: '--api-key-prefix must be 1-64 characters' }; + } + } options.language = 'Other'; return { valid: true }; } @@ -611,6 +637,26 @@ 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) { + if (options.outboundAuthType !== '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 < 1 || options.apiKeyParameterName.length > 64) + ) { + return { valid: false, error: '--api-key-parameter-name must be 1-64 characters' }; + } + if (options.apiKeyPrefix && (options.apiKeyPrefix.length < 1 || 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/primitives/GatewayTargetPrimitive.ts b/src/cli/primitives/GatewayTargetPrimitive.ts index 6ef4bf598..37237c15e 100644 --- a/src/cli/primitives/GatewayTargetPrimitive.ts +++ b/src/cli/primitives/GatewayTargetPrimitive.ts @@ -42,6 +42,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'; @@ -291,6 +292,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]') @@ -367,14 +377,26 @@ export class GatewayTargetPrimitive extends BasePrimitive { + const resolvedType = (outboundAuthMap[cliOptions.outboundAuthType.toLowerCase()] ?? 'NONE') as + | 'API_KEY' + | '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 }), + }, + }; + })() : {}), }; const result = await this.createApiGatewayTarget(config); @@ -405,12 +427,24 @@ export class GatewayTargetPrimitive extends BasePrimitive { + const resolvedType = outboundAuthMap[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 }), + }, + }; + })() : {}), }; const result = await this.createSchemaBasedGatewayTarget(config); @@ -456,12 +490,24 @@ export class GatewayTargetPrimitive extends BasePrimitive { + const resolvedType = outboundAuthMap[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 }), + }, + }; + })() : {}), }; const result = await this.createExternalGatewayTarget(config); 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; }; } From 0fffefdb40da1d64d89b5946e2d7b41b52369ace Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 10 Jun 2026 20:09:39 +0000 Subject: [PATCH 06/14] fix(cli): normalize outbound-auth in placement validation + extract helper --- .../commands/add/__tests__/validate.test.ts | 14 +++ src/cli/commands/add/validate.ts | 21 ++-- src/cli/primitives/GatewayTargetPrimitive.ts | 115 +++++++----------- 3 files changed, 67 insertions(+), 83 deletions(-) diff --git a/src/cli/commands/add/__tests__/validate.test.ts b/src/cli/commands/add/__tests__/validate.test.ts index 965f034d3..2559b0d4b 100644 --- a/src/cli/commands/add/__tests__/validate.test.ts +++ b/src/cli/commands/add/__tests__/validate.test.ts @@ -1756,4 +1756,18 @@ describe('validateAddGatewayTargetOptions — api key placement', () => { } 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); + }); }); diff --git a/src/cli/commands/add/validate.ts b/src/cli/commands/add/validate.ts index d47a76619..3778b2c05 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` }; } @@ -487,7 +487,7 @@ export async function validateAddGatewayTargetOptions(options: AddGatewayTargetO } const hasPlacementApiGw = !!(options.apiKeyLocation ?? options.apiKeyParameterName ?? options.apiKeyPrefix); if (hasPlacementApiGw) { - const apiGwNormalizedAuth = options.outboundAuthType?.toUpperCase().replace('-', '_'); + const apiGwNormalizedAuth = options.outboundAuthType?.toUpperCase().replaceAll('-', '_'); if (apiGwNormalizedAuth !== 'API_KEY') { return { valid: false, @@ -501,13 +501,10 @@ export async function validateAddGatewayTargetOptions(options: AddGatewayTargetO ) { return { valid: false, error: '--api-key-location must be HEADER or QUERY_PARAMETER' }; } - if ( - options.apiKeyParameterName && - (options.apiKeyParameterName.length < 1 || options.apiKeyParameterName.length > 64) - ) { + 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 < 1 || options.apiKeyPrefix.length > 64)) { + if (options.apiKeyPrefix && options.apiKeyPrefix.length > 64) { return { valid: false, error: '--api-key-prefix must be 1-64 characters' }; } } @@ -640,19 +637,17 @@ 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) { - if (options.outboundAuthType !== 'API_KEY') { + 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 < 1 || options.apiKeyParameterName.length > 64) - ) { + 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 < 1 || options.apiKeyPrefix.length > 64)) { + if (options.apiKeyPrefix && options.apiKeyPrefix.length > 64) { return { valid: false, error: '--api-key-prefix must be 1-64 characters' }; } } diff --git a/src/cli/primitives/GatewayTargetPrimitive.ts b/src/cli/primitives/GatewayTargetPrimitive.ts index 37237c15e..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'; @@ -72,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. @@ -337,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( @@ -376,28 +408,9 @@ export class GatewayTargetPrimitive extends BasePrimitive { - const resolvedType = (outboundAuthMap[cliOptions.outboundAuthType.toLowerCase()] ?? 'NONE') as - | 'API_KEY' - | '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 }), - }, - }; - })() - : {}), + // apiGateway only ever resolves to API_KEY | NONE (OAUTH is rejected in validation), + // so the helper's wider OAUTH | API_KEY | NONE type is narrowed here. + ...(buildOutboundAuthForCli(cliOptions) as Pick), }; const result = await this.createApiGatewayTarget(config); const output = { success: true, toolName: result.toolName }; @@ -426,26 +439,7 @@ export class GatewayTargetPrimitive extends BasePrimitive { - const resolvedType = outboundAuthMap[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 }), - }, - }; - })() - : {}), + ...buildOutboundAuthForCli(cliOptions), }; const result = await this.createSchemaBasedGatewayTarget(config); const output = { success: true, toolName: result.toolName }; @@ -489,26 +483,7 @@ export class GatewayTargetPrimitive extends BasePrimitive { - const resolvedType = outboundAuthMap[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 }), - }, - }; - })() - : {}), + ...buildOutboundAuthForCli(cliOptions), }; const result = await this.createExternalGatewayTarget(config); const output = { From fde60d65674ad5a8bcd73e6818d8e442898a7fd6 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 10 Jun 2026 20:13:58 +0000 Subject: [PATCH 07/14] feat(tui): add shared ApiKeyPlacementInput sub-form --- .../ApiKeyPlacementInput.tsx | 124 ++++++++++++++++++ .../__tests__/ApiKeyPlacementInput.test.tsx | 21 +++ .../tui/components/api-key-placement/index.ts | 2 + .../tui/components/api-key-placement/types.ts | 7 + 4 files changed, 154 insertions(+) create mode 100644 src/cli/tui/components/api-key-placement/ApiKeyPlacementInput.tsx create mode 100644 src/cli/tui/components/api-key-placement/__tests__/ApiKeyPlacementInput.test.tsx create mode 100644 src/cli/tui/components/api-key-placement/index.ts create mode 100644 src/cli/tui/components/api-key-placement/types.ts 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..214d83ee4 --- /dev/null +++ b/src/cli/tui/components/api-key-placement/__tests__/ApiKeyPlacementInput.test.tsx @@ -0,0 +1,21 @@ +import { ApiKeyPlacementInput } from '../ApiKeyPlacementInput'; +import { render } from 'ink-testing-library'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +describe('ApiKeyPlacementInput', () => { + it('renders the placement checklist with default labels', () => { + const { lastFrame } = render(); + expect(lastFrame()).toContain('Location'); + expect(lastFrame()).toContain('HEADER'); + }); + + it('completes with undefined placement when nothing is selected (skip path)', async () => { + const onComplete = vi.fn(); + const { stdin } = render(); + await new Promise(r => setTimeout(r, 20)); + stdin.write('\r'); // Enter with no selection + await new Promise(r => setTimeout(r, 20)); + expect(onComplete).toHaveBeenCalledWith(undefined); + }); +}); 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; From d001a0de0a9fec75f98cbbbbf4ee67574203e555 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 10 Jun 2026 21:48:38 +0000 Subject: [PATCH 08/14] feat(tui): collect api key placement after credential selection --- .../screens/mcp/AddGatewayTargetScreen.tsx | 48 +++++++- .../AddGatewayTargetScreen.apikey.test.tsx | 112 ++++++++++++++++++ .../screens/mcp/useAddGatewayTargetWizard.ts | 12 +- 3 files changed, 164 insertions(+), 8 deletions(-) create mode 100644 src/cli/tui/screens/mcp/__tests__/AddGatewayTargetScreen.apikey.test.tsx diff --git a/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx b/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx index fa022b4f7..f151f7aab 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 @@ -333,7 +341,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/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(); }, From bd248de8695d29172dba49e940f4c24dc16cc91f Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 10 Jun 2026 21:51:53 +0000 Subject: [PATCH 09/14] fix(import): preserve API key placement on gateway round-trip --- src/cli/aws/agentcore-control.ts | 14 ++++- .../import/__tests__/import-gateway.test.ts | 51 +++++++++++++++++++ src/cli/commands/import/import-gateway.ts | 12 +++-- 3 files changed, 72 insertions(+), 5 deletions(-) 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/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( From 754c111e64f8f4a89a418ede7899814f1b7edf97 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 10 Jun 2026 21:53:40 +0000 Subject: [PATCH 10/14] docs(gateway): document API key placement for MCP server targets --- docs/gateway.md | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) 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. From ca6e59ebd7d9558f939071503ccb7eff540c7012 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 10 Jun 2026 21:57:32 +0000 Subject: [PATCH 11/14] test(integ): mcpServer target with API key placement --- integ-tests/add-remove-gateway.test.ts | 79 ++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) 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); + }); +}); From 04c0798aa6fd28bbe65f7cf66d22efbd9b87fdd1 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 10 Jun 2026 22:06:39 +0000 Subject: [PATCH 12/14] test(tui): assert populated placement path + tighten label assertions Add a concrete end-to-end test that drives ApiKeyPlacementInput through the checklist (cursor down x2 to prefix, Space toggle, Enter confirm) into the prefix TextInput, types 'Bearer', and asserts onComplete is called with { prefix: 'Bearer' }. Also tighten the default-labels test to assert the full annotation strings ('Location (default: HEADER)' and 'Parameter name (default: x-api-key)') instead of loose substrings. --- .../__tests__/ApiKeyPlacementInput.test.tsx | 35 ++++++++++++++++--- 1 file changed, 30 insertions(+), 5 deletions(-) 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 index 214d83ee4..73853dc07 100644 --- a/src/cli/tui/components/api-key-placement/__tests__/ApiKeyPlacementInput.test.tsx +++ b/src/cli/tui/components/api-key-placement/__tests__/ApiKeyPlacementInput.test.tsx @@ -3,19 +3,44 @@ 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'); - expect(lastFrame()).toContain('HEADER'); + 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 new Promise(r => setTimeout(r, 20)); - stdin.write('\r'); // Enter with no selection - await new Promise(r => setTimeout(r, 20)); + 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' }); + }); }); From b503c6ec6bca6fceff010ebeb8dcb61b3887def5 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 11 Jun 2026 00:06:51 +0000 Subject: [PATCH 13/14] fix(cli): reject api-key flags on lambda-function-arn; fix placement help text + wizard back-nav Adds a TDD-verified guard in the lambdaFunctionArn validation branch that rejects --api-key-* placement flags before the file-existence check, closes the silent-drop bug. Updates placement sub-form help text to MULTI_SELECT and adds exitEnabled={false} to AddGatewayTargetScreen to match the sibling gateway wizard and activate onBack/onExit handlers correctly. --- .../commands/add/__tests__/validate.test.ts | 13 ++++++++ src/cli/commands/add/validate.ts | 6 ++++ .../screens/mcp/AddGatewayTargetScreen.tsx | 32 ++++++++++++------- 3 files changed, 39 insertions(+), 12 deletions(-) diff --git a/src/cli/commands/add/__tests__/validate.test.ts b/src/cli/commands/add/__tests__/validate.test.ts index 2559b0d4b..6ab9a3058 100644 --- a/src/cli/commands/add/__tests__/validate.test.ts +++ b/src/cli/commands/add/__tests__/validate.test.ts @@ -1770,4 +1770,17 @@ describe('validateAddGatewayTargetOptions — api key placement', () => { } 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/validate.ts b/src/cli/commands/add/validate.ts index 3778b2c05..55b04afa9 100644 --- a/src/cli/commands/add/validate.ts +++ b/src/cli/commands/add/validate.ts @@ -550,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(); diff --git a/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx b/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx index f151f7aab..70dc99ca7 100644 --- a/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx +++ b/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx @@ -274,22 +274,30 @@ 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 = ; return ( - + {isTargetTypeStep && ( Date: Thu, 11 Jun 2026 14:32:39 +0000 Subject: [PATCH 14/14] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94?= =?UTF-8?q?=20revert=20global=20wizard=20exit-disable;=20drop=20generated?= =?UTF-8?q?=20schema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove exitEnabled={false} from AddGatewayTargetScreen: it globally disabled Escape/Ctrl+Q exit for the whole wizard (regression on the first step where goBack is a no-op). Reverts to the pre-PR Screen behavior. The placement sub-form's own back handling is unaffected. - Revert schemas/agentcore.schema.v1.json to main: the repo regenerates the JSON schema during the release workflow; it must not be committed in feature PRs (schema-check CI gate). --- schemas/agentcore.schema.v1.json | 40 ------------------- .../screens/mcp/AddGatewayTargetScreen.tsx | 8 +--- 2 files changed, 1 insertion(+), 47 deletions(-) diff --git a/schemas/agentcore.schema.v1.json b/schemas/agentcore.schema.v1.json index 6f093988c..36e417528 100644 --- a/schemas/agentcore.schema.v1.json +++ b/schemas/agentcore.schema.v1.json @@ -1087,26 +1087,6 @@ "items": { "type": "string" } - }, - "apiKey": { - "type": "object", - "properties": { - "location": { - "type": "string", - "enum": ["HEADER", "QUERY_PARAMETER"] - }, - "parameterName": { - "type": "string", - "minLength": 1, - "maxLength": 64 - }, - "prefix": { - "type": "string", - "minLength": 1, - "maxLength": 64 - } - }, - "additionalProperties": false } }, "additionalProperties": false @@ -1765,26 +1745,6 @@ "items": { "type": "string" } - }, - "apiKey": { - "type": "object", - "properties": { - "location": { - "type": "string", - "enum": ["HEADER", "QUERY_PARAMETER"] - }, - "parameterName": { - "type": "string", - "minLength": 1, - "maxLength": 64 - }, - "prefix": { - "type": "string", - "minLength": 1, - "maxLength": 64 - } - }, - "additionalProperties": false } }, "additionalProperties": false diff --git a/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx b/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx index 70dc99ca7..848c638f5 100644 --- a/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx +++ b/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx @@ -291,13 +291,7 @@ export function AddGatewayTargetScreen({ const headerContent = ; return ( - + {isTargetTypeStep && (