Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions docs/gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <key>`. `--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.
Expand Down
79 changes: 79 additions & 0 deletions integ-tests/add-remove-gateway.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
14 changes: 12 additions & 2 deletions src/cli/aws/agentcore-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
};
}[];
}
Expand Down Expand Up @@ -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,
Expand Down
77 changes: 77 additions & 0 deletions src/cli/commands/add/__tests__/validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
3 changes: 3 additions & 0 deletions src/cli/commands/add/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ export interface AddGatewayTargetOptions {
toolFilterMethods?: string;
schema?: string;
schemaS3Account?: string;
apiKeyLocation?: string;
apiKeyParameterName?: string;
apiKeyPrefix?: string;
json?: boolean;
}

Expand Down
49 changes: 48 additions & 1 deletion src/cli/commands/add/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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` };
}
Expand All @@ -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 };
}
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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) {
Expand Down
51 changes: 51 additions & 0 deletions src/cli/commands/import/__tests__/import-gateway.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>([[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<string, string>([[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({
Expand Down
Loading
Loading