From 56134db6dda5784d8165a54f6fc3ffb874295255 Mon Sep 17 00:00:00 2001 From: Aidan Daly <99039782+aidandaly24@users.noreply.github.com> Date: Mon, 23 Feb 2026 11:55:50 -0500 Subject: [PATCH 01/28] feat: mcp gateway schema types (auth, outbound auth, OAuth credentials) (#375) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci: add feat/gateway-integration branch to workflow triggers (#376) * feat: mcp gateway schema types (auth, outbound auth, OAuth credentials) * fix: address CR feedback — scopes optional, specific validation errors --- src/cli/operations/mcp/create-mcp.ts | 4 +- src/cli/operations/remove/remove-mcp-tool.ts | 4 +- .../tui/screens/mcp/useAddGatewayWizard.ts | 1 + src/schema/schemas/__tests__/mcp.test.ts | 28 +++++++++- src/schema/schemas/agentcore-project.ts | 29 ++++++++-- src/schema/schemas/mcp.ts | 55 ++++++++++++++++++- 6 files changed, 108 insertions(+), 13 deletions(-) diff --git a/src/cli/operations/mcp/create-mcp.ts b/src/cli/operations/mcp/create-mcp.ts index a03543e5..5f4447e8 100644 --- a/src/cli/operations/mcp/create-mcp.ts +++ b/src/cli/operations/mcp/create-mcp.ts @@ -126,7 +126,7 @@ export async function getExistingToolNames(): Promise { // Gateway targets for (const gateway of mcpSpec.agentCoreGateways) { for (const target of gateway.targets) { - for (const toolDef of target.toolDefinitions) { + for (const toolDef of target.toolDefinitions ?? []) { toolNames.push(toolDef.name); } } @@ -256,7 +256,7 @@ export async function createToolFromWizard(config: AddMcpToolConfig): Promise t.name === toolDef.name)) { + if ((existingTarget.toolDefinitions ?? []).some(t => t.name === toolDef.name)) { throw new Error(`Tool "${toolDef.name}" already exists in gateway "${gateway.name}".`); } } diff --git a/src/cli/operations/remove/remove-mcp-tool.ts b/src/cli/operations/remove/remove-mcp-tool.ts index 9b02be84..dd4dab28 100644 --- a/src/cli/operations/remove/remove-mcp-tool.ts +++ b/src/cli/operations/remove/remove-mcp-tool.ts @@ -109,7 +109,7 @@ export async function previewRemoveMcpTool(tool: RemovableMcpTool): Promise g.name === tool.gatewayName); const target = gateway?.targets.find(t => t.name === tool.name); if (target) { - for (const toolDef of target.toolDefinitions) { + for (const toolDef of target.toolDefinitions ?? []) { toolNamesToRemove.push(toolDef.name); } } diff --git a/src/cli/tui/screens/mcp/useAddGatewayWizard.ts b/src/cli/tui/screens/mcp/useAddGatewayWizard.ts index 48c1f0b4..136c8899 100644 --- a/src/cli/tui/screens/mcp/useAddGatewayWizard.ts +++ b/src/cli/tui/screens/mcp/useAddGatewayWizard.ts @@ -5,6 +5,7 @@ import { useCallback, useMemo, useState } from 'react'; /** Maps authorizer type to the next step after authorizer selection */ const AUTHORIZER_NEXT_STEP: Record = { NONE: 'agents', + AWS_IAM: 'agents', CUSTOM_JWT: 'jwt-config', }; diff --git a/src/schema/schemas/__tests__/mcp.test.ts b/src/schema/schemas/__tests__/mcp.test.ts index cd437fd2..2414406b 100644 --- a/src/schema/schemas/__tests__/mcp.test.ts +++ b/src/schema/schemas/__tests__/mcp.test.ts @@ -261,6 +261,11 @@ describe('AgentCoreGatewayTargetSchema', () => { name: 'myTarget', targetType: 'lambda', toolDefinitions: [validToolDef], + compute: { + host: 'Lambda', + implementation: { language: 'Python', path: 'tools', handler: 'h' }, + pythonVersion: 'PYTHON_3_12', + }, }); expect(result.success).toBe(true); }); @@ -270,6 +275,11 @@ describe('AgentCoreGatewayTargetSchema', () => { name: 'myTarget', targetType: 'lambda', toolDefinitions: [], + compute: { + host: 'Lambda', + implementation: { language: 'Python', path: 'tools', handler: 'h' }, + pythonVersion: 'PYTHON_3_12', + }, }); expect(result.success).toBe(false); }); @@ -303,6 +313,11 @@ describe('AgentCoreGatewaySchema', () => { name: 'target1', targetType: 'lambda', toolDefinitions: [validToolDef], + compute: { + host: 'Lambda', + implementation: { language: 'Python', path: 'tools', handler: 'h' }, + pythonVersion: 'PYTHON_3_12', + }, }, ], }; @@ -387,7 +402,18 @@ describe('AgentCoreMcpSpecSchema', () => { agentCoreGateways: [ { name: 'gw1', - targets: [{ name: 't1', targetType: 'lambda', toolDefinitions: [validToolDef] }], + targets: [ + { + name: 't1', + targetType: 'lambda', + toolDefinitions: [validToolDef], + compute: { + host: 'Lambda', + implementation: { language: 'Python', path: 'tools', handler: 'h' }, + pythonVersion: 'PYTHON_3_12', + }, + }, + ], }, ], }); diff --git a/src/schema/schemas/agentcore-project.ts b/src/schema/schemas/agentcore-project.ts index 49a7f576..13f8241f 100644 --- a/src/schema/schemas/agentcore-project.ts +++ b/src/schema/schemas/agentcore-project.ts @@ -71,9 +71,6 @@ export type Memory = z.infer; // Credential Schema // ============================================================================ -export const CredentialTypeSchema = z.literal('ApiKeyCredentialProvider'); -export type CredentialType = z.infer; - export const CredentialNameSchema = z .string() .min(3, 'Credential name must be at least 3 characters') @@ -83,11 +80,33 @@ export const CredentialNameSchema = z 'Must contain only alphanumeric characters, underscores, dots, and hyphens (3-255 chars)' ); -export const CredentialSchema = z.object({ - type: CredentialTypeSchema, +export const CredentialTypeSchema = z.enum(['ApiKeyCredentialProvider', 'OAuthCredentialProvider']); +export type CredentialType = z.infer; + +export const ApiKeyCredentialSchema = z.object({ + type: z.literal('ApiKeyCredentialProvider'), + name: CredentialNameSchema, +}); + +export type ApiKeyCredential = z.infer; + +export const OAuthCredentialSchema = z.object({ + type: z.literal('OAuthCredentialProvider'), name: CredentialNameSchema, + /** OIDC discovery URL for the OAuth provider */ + discoveryUrl: z.string().url(), + /** Scopes this credential provider supports */ + scopes: z.array(z.string()).optional(), + /** Credential provider vendor type */ + vendor: z.string().default('CustomOauth2'), + /** Whether this credential was auto-created by the CLI (e.g., for CUSTOM_JWT inbound auth) */ + managed: z.boolean().optional(), }); +export type OAuthCredential = z.infer; + +export const CredentialSchema = z.discriminatedUnion('type', [ApiKeyCredentialSchema, OAuthCredentialSchema]); + export type Credential = z.infer; // ============================================================================ diff --git a/src/schema/schemas/mcp.ts b/src/schema/schemas/mcp.ts index e3890df1..f529da4a 100644 --- a/src/schema/schemas/mcp.ts +++ b/src/schema/schemas/mcp.ts @@ -15,7 +15,7 @@ export type GatewayTargetType = z.infer; // Gateway Authorization Schemas // ============================================================================ -export const GatewayAuthorizerTypeSchema = z.enum(['NONE', 'CUSTOM_JWT']); +export const GatewayAuthorizerTypeSchema = z.enum(['NONE', 'AWS_IAM', 'CUSTOM_JWT']); export type GatewayAuthorizerType = z.infer; /** OIDC well-known configuration endpoint suffix (per OpenID Connect Discovery 1.0 spec) */ @@ -44,6 +44,7 @@ export const CustomJwtAuthorizerConfigSchema = z.object({ allowedAudience: z.array(z.string().min(1)), /** List of allowed client IDs */ allowedClients: z.array(z.string().min(1)).min(1), + allowedScopes: z.array(z.string().min(1)).optional(), }); export type CustomJwtAuthorizerConfig = z.infer; @@ -57,6 +58,19 @@ export const GatewayAuthorizerConfigSchema = z.object({ export type GatewayAuthorizerConfig = z.infer; +export const OutboundAuthTypeSchema = z.enum(['OAUTH', 'API_KEY', 'NONE']); +export type OutboundAuthType = z.infer; + +export const OutboundAuthSchema = z + .object({ + type: OutboundAuthTypeSchema.default('NONE'), + credentialName: z.string().min(1).optional(), + scopes: z.array(z.string()).optional(), + }) + .strict(); + +export type OutboundAuth = z.infer; + export const McpImplLanguageSchema = z.enum(['TypeScript', 'Python']); export type McpImplementationLanguage = z.infer; @@ -262,10 +276,45 @@ export const AgentCoreGatewayTargetSchema = z .object({ name: z.string().min(1), targetType: GatewayTargetTypeSchema, - toolDefinitions: z.array(ToolDefinitionSchema).min(1), + /** Tool definitions. Required for Lambda targets. Optional for MCP Server (discovered via tools/list). */ + toolDefinitions: z.array(ToolDefinitionSchema).optional(), + /** Compute configuration. Required for Lambda/Runtime scaffold targets. */ compute: ToolComputeConfigSchema.optional(), + /** MCP Server endpoint URL. Required for external MCP Server targets. */ + endpoint: z.string().url().optional(), + /** Outbound auth configuration for the target. */ + outboundAuth: OutboundAuthSchema.optional(), }) - .strict(); + .strict() + .superRefine((data, ctx) => { + if (data.targetType === 'mcpServer' && !data.compute && !data.endpoint) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'MCP Server targets require either an endpoint URL or compute configuration.', + }); + } + if (data.targetType === 'lambda' && !data.compute) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Lambda targets require compute configuration.', + path: ['compute'], + }); + } + if (data.targetType === 'lambda' && (!data.toolDefinitions || data.toolDefinitions.length === 0)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Lambda targets require at least one tool definition.', + path: ['toolDefinitions'], + }); + } + if (data.outboundAuth && data.outboundAuth.type !== 'NONE' && !data.outboundAuth.credentialName) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `${data.outboundAuth.type} outbound auth requires a credentialName.`, + path: ['outboundAuth', 'credentialName'], + }); + } + }); export type AgentCoreGatewayTarget = z.infer; From 7f2efe9dbfd9ffb6e0022c035bde9eabc1bc0f26 Mon Sep 17 00:00:00 2001 From: Aidan Daly <99039782+aidandaly24@users.noreply.github.com> Date: Mon, 23 Feb 2026 12:19:30 -0500 Subject: [PATCH 02/28] refactor: rename mcp-tool to gateway-target across CLI (#377) --- ...ool.test.ts => add-gateway-target.test.ts} | 31 ++++---- .../commands/add/__tests__/validate.test.ts | 28 ++++---- src/cli/commands/add/actions.ts | 20 ++++-- src/cli/commands/add/command.tsx | 24 ++++--- src/cli/commands/add/types.ts | 4 +- src/cli/commands/add/validate.ts | 4 +- ....test.ts => remove-gateway-target.test.ts} | 20 +++--- src/cli/commands/remove/actions.ts | 10 +-- src/cli/commands/remove/command.tsx | 2 +- src/cli/commands/remove/types.ts | 2 +- src/cli/logging/remove-logger.ts | 2 +- src/cli/operations/mcp/create-mcp.ts | 12 ++-- src/cli/operations/remove/index.ts | 2 +- ...e-mcp-tool.ts => remove-gateway-target.ts} | 14 ++-- ...olRenderer.ts => GatewayTargetRenderer.ts} | 2 +- src/cli/templates/index.ts | 2 +- .../tui/hooks/__tests__/useRemove.test.tsx | 6 +- src/cli/tui/hooks/index.ts | 2 +- src/cli/tui/hooks/useCreateMcp.ts | 6 +- src/cli/tui/hooks/useRemove.ts | 30 ++++---- src/cli/tui/screens/add/AddFlow.tsx | 8 +-- src/cli/tui/screens/add/AddScreen.tsx | 2 +- ...pToolFlow.tsx => AddGatewayTargetFlow.tsx} | 20 +++--- ...lScreen.tsx => AddGatewayTargetScreen.tsx} | 14 ++-- src/cli/tui/screens/mcp/index.ts | 10 +-- src/cli/tui/screens/mcp/types.ts | 6 +- ...Wizard.ts => useAddGatewayTargetWizard.ts} | 12 ++-- src/cli/tui/screens/remove/RemoveFlow.tsx | 71 ++++++++++--------- ...reen.tsx => RemoveGatewayTargetScreen.tsx} | 10 +-- src/cli/tui/screens/remove/RemoveScreen.tsx | 2 +- src/cli/tui/screens/remove/index.ts | 2 +- .../tui/screens/schema/McpGuidedEditor.tsx | 2 +- src/cli/tui/utils/__tests__/commands.test.ts | 6 +- src/cli/tui/utils/commands.ts | 2 +- 34 files changed, 207 insertions(+), 183 deletions(-) rename src/cli/commands/add/__tests__/{add-mcp-tool.test.ts => add-gateway-target.test.ts} (92%) rename src/cli/commands/remove/__tests__/{remove-mcp-tool.test.ts => remove-gateway-target.test.ts} (88%) rename src/cli/operations/remove/{remove-mcp-tool.ts => remove-gateway-target.ts} (94%) rename src/cli/templates/{McpToolRenderer.ts => GatewayTargetRenderer.ts} (98%) rename src/cli/tui/screens/mcp/{AddMcpToolFlow.tsx => AddGatewayTargetFlow.tsx} (94%) rename src/cli/tui/screens/mcp/{AddMcpToolScreen.tsx => AddGatewayTargetScreen.tsx} (94%) rename src/cli/tui/screens/mcp/{useAddMcpWizard.ts => useAddGatewayTargetWizard.ts} (86%) rename src/cli/tui/screens/remove/{RemoveMcpToolScreen.tsx => RemoveGatewayTargetScreen.tsx} (71%) diff --git a/src/cli/commands/add/__tests__/add-mcp-tool.test.ts b/src/cli/commands/add/__tests__/add-gateway-target.test.ts similarity index 92% rename from src/cli/commands/add/__tests__/add-mcp-tool.test.ts rename to src/cli/commands/add/__tests__/add-gateway-target.test.ts index 22c7b42b..c6d9846b 100644 --- a/src/cli/commands/add/__tests__/add-mcp-tool.test.ts +++ b/src/cli/commands/add/__tests__/add-gateway-target.test.ts @@ -6,18 +6,18 @@ import { join } from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; // MCP Tool feature is disabled (coming soon) - skip all tests -describe.skip('add mcp-tool command', () => { +describe.skip('add gateway-target command', () => { let testDir: string; let projectDir: string; const agentName = 'TestAgent'; const gatewayName = 'test-gateway'; // Used in skipped behind-gateway tests beforeAll(async () => { - testDir = join(tmpdir(), `agentcore-add-mcp-tool-${randomUUID()}`); + testDir = join(tmpdir(), `agentcore-add-gateway-target-${randomUUID()}`); await mkdir(testDir, { recursive: true }); // Create project with agent - const projectName = 'McpToolProj'; + const projectName = 'GatewayTargetProj'; let result = await runCLI(['create', '--name', projectName, '--no-agent'], testDir); if (result.exitCode !== 0) { throw new Error(`Failed to create project: ${result.stdout} ${result.stderr}`); @@ -54,7 +54,7 @@ describe.skip('add mcp-tool command', () => { describe('validation', () => { it('requires name flag', async () => { - const result = await runCLI(['add', 'mcp-tool', '--json'], projectDir); + const result = await runCLI(['add', 'gateway-target', '--json'], projectDir); expect(result.exitCode).toBe(1); const json = JSON.parse(result.stdout); expect(json.success).toBe(false); @@ -62,7 +62,10 @@ describe.skip('add mcp-tool command', () => { }); it('requires exposure flag', async () => { - const result = await runCLI(['add', 'mcp-tool', '--name', 'test', '--language', 'Python', '--json'], projectDir); + const result = await runCLI( + ['add', 'gateway-target', '--name', 'test', '--language', 'Python', '--json'], + projectDir + ); expect(result.exitCode).toBe(1); const json = JSON.parse(result.stdout); expect(json.success).toBe(false); @@ -73,7 +76,7 @@ describe.skip('add mcp-tool command', () => { const result = await runCLI( [ 'add', - 'mcp-tool', + 'gateway-target', '--name', 'test', '--language', @@ -99,7 +102,7 @@ describe.skip('add mcp-tool command', () => { const result = await runCLI( [ 'add', - 'mcp-tool', + 'gateway-target', '--name', 'container-tool', '--language', @@ -130,7 +133,7 @@ describe.skip('add mcp-tool command', () => { const result = await runCLI( [ 'add', - 'mcp-tool', + 'gateway-target', '--name', toolName, '--language', @@ -163,7 +166,7 @@ describe.skip('add mcp-tool command', () => { it('requires agents for mcp-runtime', async () => { const result = await runCLI( - ['add', 'mcp-tool', '--name', 'no-agents', '--language', 'Python', '--exposure', 'mcp-runtime', '--json'], + ['add', 'gateway-target', '--name', 'no-agents', '--language', 'Python', '--exposure', 'mcp-runtime', '--json'], projectDir ); expect(result.exitCode).toBe(1); @@ -176,7 +179,7 @@ describe.skip('add mcp-tool command', () => { const result = await runCLI( [ 'add', - 'mcp-tool', + 'gateway-target', '--name', 'runtime-container', '--language', @@ -204,7 +207,7 @@ describe.skip('add mcp-tool command', () => { const result = await runCLI( [ 'add', - 'mcp-tool', + 'gateway-target', '--name', toolName, '--language', @@ -236,7 +239,7 @@ describe.skip('add mcp-tool command', () => { const result = await runCLI( [ 'add', - 'mcp-tool', + 'gateway-target', '--name', 'no-gw', '--language', @@ -259,7 +262,7 @@ describe.skip('add mcp-tool command', () => { const result = await runCLI( [ 'add', - 'mcp-tool', + 'gateway-target', '--name', 'no-host', '--language', @@ -282,7 +285,7 @@ describe.skip('add mcp-tool command', () => { const result = await runCLI( [ 'add', - 'mcp-tool', + 'gateway-target', '--name', 'gateway-container', '--language', diff --git a/src/cli/commands/add/__tests__/validate.test.ts b/src/cli/commands/add/__tests__/validate.test.ts index cefc1a73..08ba8de5 100644 --- a/src/cli/commands/add/__tests__/validate.test.ts +++ b/src/cli/commands/add/__tests__/validate.test.ts @@ -1,15 +1,15 @@ import type { AddAgentOptions, AddGatewayOptions, + AddGatewayTargetOptions, AddIdentityOptions, - AddMcpToolOptions, AddMemoryOptions, } from '../types.js'; import { validateAddAgentOptions, validateAddGatewayOptions, + validateAddGatewayTargetOptions, validateAddIdentityOptions, - validateAddMcpToolOptions, validateAddMemoryOptions, } from '../validate.js'; import { describe, expect, it } from 'vitest'; @@ -46,14 +46,14 @@ const validGatewayOptionsJwt: AddGatewayOptions = { allowedClients: 'client1,client2', }; -const validMcpToolOptionsMcpRuntime: AddMcpToolOptions = { +const validGatewayTargetOptionsMcpRuntime: AddGatewayTargetOptions = { name: 'test-tool', language: 'Python', exposure: 'mcp-runtime', agents: 'Agent1,Agent2', }; -const validMcpToolOptionsBehindGateway: AddMcpToolOptions = { +const validGatewayTargetOptionsBehindGateway: AddGatewayTargetOptions = { name: 'test-tool', language: 'Python', exposure: 'behind-gateway', @@ -235,18 +235,18 @@ describe('validate', () => { }); }); - describe('validateAddMcpToolOptions', () => { + describe('validateAddGatewayTargetOptions', () => { // AC15: Required fields validated it('returns error for missing required fields', () => { - const requiredFields: { field: keyof AddMcpToolOptions; error: string }[] = [ + const requiredFields: { field: keyof AddGatewayTargetOptions; error: string }[] = [ { field: 'name', error: '--name is required' }, { field: 'language', error: '--language is required' }, { field: 'exposure', error: '--exposure is required' }, ]; for (const { field, error } of requiredFields) { - const opts = { ...validMcpToolOptionsMcpRuntime, [field]: undefined }; - const result = validateAddMcpToolOptions(opts); + const opts = { ...validGatewayTargetOptionsMcpRuntime, [field]: undefined }; + const result = validateAddGatewayTargetOptions(opts); expect(result.valid, `Should fail for missing ${String(field)}`).toBe(false); expect(result.error).toBe(error); } @@ -254,36 +254,36 @@ describe('validate', () => { // AC16: Invalid values rejected it('returns error for invalid values', () => { - let result = validateAddMcpToolOptions({ ...validMcpToolOptionsMcpRuntime, language: 'Java' as any }); + let result = validateAddGatewayTargetOptions({ ...validGatewayTargetOptionsMcpRuntime, language: 'Java' as any }); expect(result.valid).toBe(false); expect(result.error?.includes('Invalid language')).toBeTruthy(); - result = validateAddMcpToolOptions({ ...validMcpToolOptionsMcpRuntime, exposure: 'invalid' as any }); + result = validateAddGatewayTargetOptions({ ...validGatewayTargetOptionsMcpRuntime, exposure: 'invalid' as any }); expect(result.valid).toBe(false); expect(result.error?.includes('Invalid exposure')).toBeTruthy(); }); // AC17: mcp-runtime exposure requires agents it('returns error for mcp-runtime without agents', () => { - let result = validateAddMcpToolOptions({ ...validMcpToolOptionsMcpRuntime, agents: undefined }); + let result = validateAddGatewayTargetOptions({ ...validGatewayTargetOptionsMcpRuntime, agents: undefined }); expect(result.valid).toBe(false); expect(result.error).toBe('--agents is required for mcp-runtime exposure'); - result = validateAddMcpToolOptions({ ...validMcpToolOptionsMcpRuntime, agents: ',,,' }); + result = validateAddGatewayTargetOptions({ ...validGatewayTargetOptionsMcpRuntime, agents: ',,,' }); expect(result.valid).toBe(false); expect(result.error).toBe('At least one agent is required'); }); // AC18: behind-gateway exposure is disabled (coming soon) it('returns coming soon error for behind-gateway exposure', () => { - const result = validateAddMcpToolOptions({ ...validMcpToolOptionsBehindGateway }); + const result = validateAddGatewayTargetOptions({ ...validGatewayTargetOptionsBehindGateway }); expect(result.valid).toBe(false); expect(result.error).toContain('coming soon'); }); // AC19: Valid options pass it('passes for valid mcp-runtime options', () => { - expect(validateAddMcpToolOptions(validMcpToolOptionsMcpRuntime)).toEqual({ valid: true }); + expect(validateAddGatewayTargetOptions(validGatewayTargetOptionsMcpRuntime)).toEqual({ valid: true }); }); }); diff --git a/src/cli/commands/add/actions.ts b/src/cli/commands/add/actions.ts index 1c94737e..c6df1ff1 100644 --- a/src/cli/commands/add/actions.ts +++ b/src/cli/commands/add/actions.ts @@ -27,9 +27,15 @@ import { createGatewayFromWizard, createToolFromWizard } from '../../operations/ import { createMemory } from '../../operations/memory/create-memory'; import { createRenderer } from '../../templates'; import type { MemoryOption } from '../../tui/screens/generate/types'; -import type { AddGatewayConfig, AddMcpToolConfig } from '../../tui/screens/mcp/types'; +import type { AddGatewayConfig, AddGatewayTargetConfig } from '../../tui/screens/mcp/types'; import { DEFAULT_EVENT_EXPIRY } from '../../tui/screens/memory/types'; -import type { AddAgentResult, AddGatewayResult, AddIdentityResult, AddMcpToolResult, AddMemoryResult } from './types'; +import type { + AddAgentResult, + AddGatewayResult, + AddGatewayTargetResult, + AddIdentityResult, + AddMemoryResult, +} from './types'; import { mkdirSync } from 'fs'; import { dirname, join } from 'path'; @@ -57,7 +63,7 @@ export interface ValidatedAddGatewayOptions { agents?: string; } -export interface ValidatedAddMcpToolOptions { +export interface ValidatedAddGatewayTargetOptions { name: string; description?: string; language: 'Python' | 'TypeScript' | 'Other'; @@ -276,7 +282,7 @@ export async function handleAddGateway(options: ValidatedAddGatewayOptions): Pro } // MCP Tool handler -function buildMcpToolConfig(options: ValidatedAddMcpToolOptions): AddMcpToolConfig { +function buildGatewayTargetConfig(options: ValidatedAddGatewayTargetOptions): AddGatewayTargetConfig { const sourcePath = `${APP_DIR}/${MCP_APP_SUBDIR}/${options.name}`; const description = options.description ?? `Tool for ${options.name}`; @@ -303,9 +309,11 @@ function buildMcpToolConfig(options: ValidatedAddMcpToolOptions): AddMcpToolConf }; } -export async function handleAddMcpTool(options: ValidatedAddMcpToolOptions): Promise { +export async function handleAddGatewayTarget( + options: ValidatedAddGatewayTargetOptions +): Promise { try { - const config = buildMcpToolConfig(options); + const config = buildGatewayTargetConfig(options); const result = await createToolFromWizard(config); return { success: true, toolName: result.toolName, sourcePath: result.projectPath }; } catch (err) { diff --git a/src/cli/commands/add/command.tsx b/src/cli/commands/add/command.tsx index 58a95503..0b1e8abe 100644 --- a/src/cli/commands/add/command.tsx +++ b/src/cli/commands/add/command.tsx @@ -1,19 +1,25 @@ import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; import { requireProject } from '../../tui/guards'; import { AddFlow } from '../../tui/screens/add/AddFlow'; -import { handleAddAgent, handleAddGateway, handleAddIdentity, handleAddMcpTool, handleAddMemory } from './actions'; +import { + handleAddAgent, + handleAddGateway, + handleAddGatewayTarget, + handleAddIdentity, + handleAddMemory, +} from './actions'; import type { AddAgentOptions, AddGatewayOptions, + AddGatewayTargetOptions, AddIdentityOptions, - AddMcpToolOptions, AddMemoryOptions, } from './types'; import { validateAddAgentOptions, validateAddGatewayOptions, + validateAddGatewayTargetOptions, validateAddIdentityOptions, - validateAddMcpToolOptions, validateAddMemoryOptions, } from './validate'; import type { Command } from '@commander-js/extra-typings'; @@ -92,8 +98,8 @@ async function _handleAddGatewayCLI(options: AddGatewayOptions): Promise { } // MCP Tool disabled - prefix with underscore until feature is re-enabled -async function _handleAddMcpToolCLI(options: AddMcpToolOptions): Promise { - const validation = validateAddMcpToolOptions(options); +async function _handleAddGatewayTargetCLI(options: AddGatewayTargetOptions): Promise { + const validation = validateAddGatewayTargetOptions(options); if (!validation.valid) { if (options.json) { console.log(JSON.stringify({ success: false, error: validation.error })); @@ -103,7 +109,7 @@ async function _handleAddMcpToolCLI(options: AddMcpToolOptions): Promise { process.exit(1); } - const result = await handleAddMcpTool({ + const result = await handleAddGatewayTarget({ name: options.name!, description: options.description, language: options.language! as 'Python' | 'TypeScript', @@ -252,10 +258,10 @@ export function registerAdd(program: Command) { process.exit(1); }); - // Subcommand: add mcp-tool (disabled - coming soon) + // Subcommand: add gateway-target (disabled - coming soon) addCmd - .command('mcp-tool', { hidden: true }) - .description('Add an MCP tool to the project') + .command('gateway-target', { hidden: true }) + .description('Add a gateway target to the project') .option('--name ', 'Tool name') .option('--description ', 'Tool description') .option('--language ', 'Language: Python or TypeScript') diff --git a/src/cli/commands/add/types.ts b/src/cli/commands/add/types.ts index f20c3b01..ad002ff8 100644 --- a/src/cli/commands/add/types.ts +++ b/src/cli/commands/add/types.ts @@ -42,7 +42,7 @@ export interface AddGatewayResult { } // MCP Tool types -export interface AddMcpToolOptions { +export interface AddGatewayTargetOptions { name?: string; description?: string; language?: 'Python' | 'TypeScript' | 'Other'; @@ -53,7 +53,7 @@ export interface AddMcpToolOptions { json?: boolean; } -export interface AddMcpToolResult { +export interface AddGatewayTargetResult { success: boolean; toolName?: string; sourcePath?: string; diff --git a/src/cli/commands/add/validate.ts b/src/cli/commands/add/validate.ts index 7ad3de1c..037a6df1 100644 --- a/src/cli/commands/add/validate.ts +++ b/src/cli/commands/add/validate.ts @@ -10,8 +10,8 @@ import { import type { AddAgentOptions, AddGatewayOptions, + AddGatewayTargetOptions, AddIdentityOptions, - AddMcpToolOptions, AddMemoryOptions, } from './types'; @@ -154,7 +154,7 @@ export function validateAddGatewayOptions(options: AddGatewayOptions): Validatio } // MCP Tool validation -export function validateAddMcpToolOptions(options: AddMcpToolOptions): ValidationResult { +export function validateAddGatewayTargetOptions(options: AddGatewayTargetOptions): ValidationResult { if (!options.name) { return { valid: false, error: '--name is required' }; } diff --git a/src/cli/commands/remove/__tests__/remove-mcp-tool.test.ts b/src/cli/commands/remove/__tests__/remove-gateway-target.test.ts similarity index 88% rename from src/cli/commands/remove/__tests__/remove-mcp-tool.test.ts rename to src/cli/commands/remove/__tests__/remove-gateway-target.test.ts index 0e5e797e..00f01e9f 100644 --- a/src/cli/commands/remove/__tests__/remove-mcp-tool.test.ts +++ b/src/cli/commands/remove/__tests__/remove-gateway-target.test.ts @@ -6,18 +6,18 @@ import { join } from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; // MCP Tool feature is disabled (coming soon) - skip all tests -describe.skip('remove mcp-tool command', () => { +describe.skip('remove gateway-target command', () => { let testDir: string; let projectDir: string; const agentName = 'TestAgent'; const runtimeToolName = 'RuntimeTool'; beforeAll(async () => { - testDir = join(tmpdir(), `agentcore-remove-mcp-tool-${randomUUID()}`); + testDir = join(tmpdir(), `agentcore-remove-gateway-target-${randomUUID()}`); await mkdir(testDir, { recursive: true }); // Create project - const projectName = 'RemoveMcpToolProj'; + const projectName = 'RemoveGatewayTargetProj'; let result = await runCLI(['create', '--name', projectName, '--no-agent'], testDir); if (result.exitCode !== 0) { throw new Error(`Failed to create project: ${result.stdout} ${result.stderr}`); @@ -51,7 +51,7 @@ describe.skip('remove mcp-tool command', () => { result = await runCLI( [ 'add', - 'mcp-tool', + 'gateway-target', '--name', runtimeToolName, '--language', @@ -75,7 +75,7 @@ describe.skip('remove mcp-tool command', () => { describe('validation', () => { it('requires name flag', async () => { - const result = await runCLI(['remove', 'mcp-tool', '--json'], projectDir); + const result = await runCLI(['remove', 'gateway-target', '--json'], projectDir); expect(result.exitCode).toBe(1); const json = JSON.parse(result.stdout); expect(json.success).toBe(false); @@ -83,7 +83,7 @@ describe.skip('remove mcp-tool command', () => { }); it('rejects non-existent tool', async () => { - const result = await runCLI(['remove', 'mcp-tool', '--name', 'nonexistent', '--json'], projectDir); + const result = await runCLI(['remove', 'gateway-target', '--name', 'nonexistent', '--json'], projectDir); expect(result.exitCode).toBe(1); const json = JSON.parse(result.stdout); expect(json.success).toBe(false); @@ -98,7 +98,7 @@ describe.skip('remove mcp-tool command', () => { await runCLI( [ 'add', - 'mcp-tool', + 'gateway-target', '--name', tempTool, '--language', @@ -112,7 +112,7 @@ describe.skip('remove mcp-tool command', () => { projectDir ); - const result = await runCLI(['remove', 'mcp-tool', '--name', tempTool, '--json'], projectDir); + const result = await runCLI(['remove', 'gateway-target', '--name', tempTool, '--json'], projectDir); expect(result.exitCode, `stdout: ${result.stdout}`).toBe(0); const json = JSON.parse(result.stdout); expect(json.success).toBe(true); @@ -143,7 +143,7 @@ describe.skip('remove mcp-tool command', () => { const addResult = await runCLI( [ 'add', - 'mcp-tool', + 'gateway-target', '--name', tempTool, '--language', @@ -160,7 +160,7 @@ describe.skip('remove mcp-tool command', () => { ); expect(addResult.exitCode, `add failed: ${addResult.stdout} ${addResult.stderr}`).toBe(0); - const result = await runCLI(['remove', 'mcp-tool', '--name', tempTool, '--json'], projectDir); + const result = await runCLI(['remove', 'gateway-target', '--name', tempTool, '--json'], projectDir); expect(result.exitCode, `stdout: ${result.stdout}`).toBe(0); const json = JSON.parse(result.stdout); expect(json.success).toBe(true); diff --git a/src/cli/commands/remove/actions.ts b/src/cli/commands/remove/actions.ts index 22518094..3a9ad9e8 100644 --- a/src/cli/commands/remove/actions.ts +++ b/src/cli/commands/remove/actions.ts @@ -1,11 +1,11 @@ import { ConfigIO } from '../../../lib'; import { getErrorMessage } from '../../errors'; import { - getRemovableMcpTools, + getRemovableGatewayTargets, removeAgent, removeGateway, + removeGatewayTarget, removeIdentity, - removeMcpTool, removeMemory, } from '../../operations/remove'; import type { RemoveAllOptions, RemoveResult, ResourceType } from './types'; @@ -46,11 +46,11 @@ export async function handleRemove(options: ValidatedRemoveOptions): Promise t.name === name); if (!tool) return { success: false, error: `MCP tool '${name}' not found` }; - const result = await removeMcpTool(tool); + const result = await removeGatewayTarget(tool); if (!result.ok) return { success: false, error: result.error }; return { success: true, diff --git a/src/cli/commands/remove/command.tsx b/src/cli/commands/remove/command.tsx index 576e204e..11f1dd8f 100644 --- a/src/cli/commands/remove/command.tsx +++ b/src/cli/commands/remove/command.tsx @@ -144,7 +144,7 @@ export const registerRemove = (program: Command) => { // MCP Tool disabled - replace with registerResourceRemove() call when enabling removeCommand - .command('mcp-tool', { hidden: true }) + .command('gateway-target', { hidden: true }) .description('Remove an MCP tool from the project') .option('--name ', 'Name of resource to remove') .option('--force', 'Skip confirmation prompt') diff --git a/src/cli/commands/remove/types.ts b/src/cli/commands/remove/types.ts index 3ad2cd20..d4dbe99b 100644 --- a/src/cli/commands/remove/types.ts +++ b/src/cli/commands/remove/types.ts @@ -1,4 +1,4 @@ -export type ResourceType = 'agent' | 'gateway' | 'mcp-tool' | 'memory' | 'identity'; +export type ResourceType = 'agent' | 'gateway' | 'gateway-target' | 'memory' | 'identity'; export interface RemoveOptions { resourceType: ResourceType; diff --git a/src/cli/logging/remove-logger.ts b/src/cli/logging/remove-logger.ts index ebf97c40..234f820f 100644 --- a/src/cli/logging/remove-logger.ts +++ b/src/cli/logging/remove-logger.ts @@ -7,7 +7,7 @@ const REMOVE_LOGS_SUBDIR = 'remove'; export interface RemoveLoggerOptions { /** Type of resource being removed */ - resourceType: 'agent' | 'memory' | 'identity' | 'gateway' | 'mcp-tool'; + resourceType: 'agent' | 'memory' | 'identity' | 'gateway' | 'gateway-target'; /** Name of the resource being removed */ resourceName: string; } diff --git a/src/cli/operations/mcp/create-mcp.ts b/src/cli/operations/mcp/create-mcp.ts index 5f4447e8..f246fc69 100644 --- a/src/cli/operations/mcp/create-mcp.ts +++ b/src/cli/operations/mcp/create-mcp.ts @@ -10,8 +10,8 @@ import type { FilePath, } from '../../../schema'; import { AgentCoreCliMcpDefsSchema, ToolDefinitionSchema } from '../../../schema'; -import { getTemplateToolDefinitions, renderMcpToolTemplate } from '../../templates/McpToolRenderer'; -import type { AddGatewayConfig, AddMcpToolConfig } from '../../tui/screens/mcp/types'; +import { getTemplateToolDefinitions, renderGatewayTargetTemplate } from '../../templates/GatewayTargetRenderer'; +import type { AddGatewayConfig, AddGatewayTargetConfig } from '../../tui/screens/mcp/types'; import { DEFAULT_HANDLER, DEFAULT_NODE_VERSION, DEFAULT_PYTHON_VERSION } from '../../tui/screens/mcp/types'; import { existsSync } from 'fs'; import { mkdir, readFile, writeFile } from 'fs/promises'; @@ -171,7 +171,7 @@ export async function createGatewayFromWizard(config: AddGatewayConfig): Promise return { name: config.name }; } -function validateMcpToolLanguage(language: string): asserts language is 'Python' | 'TypeScript' | 'Other' { +function validateGatewayTargetLanguage(language: string): asserts language is 'Python' | 'TypeScript' | 'Other' { if (language !== 'Python' && language !== 'TypeScript' && language !== 'Other') { throw new Error(`MCP tools for language "${language}" are not yet supported.`); } @@ -180,8 +180,8 @@ function validateMcpToolLanguage(language: string): asserts language is 'Python' /** * Create an MCP tool (MCP runtime or behind gateway). */ -export async function createToolFromWizard(config: AddMcpToolConfig): Promise { - validateMcpToolLanguage(config.language); +export async function createToolFromWizard(config: AddGatewayTargetConfig): Promise { + validateGatewayTargetLanguage(config.language); const configIO = new ConfigIO(); const mcpSpec: AgentCoreMcpSpec = configIO.configExists('mcp') @@ -330,7 +330,7 @@ export async function createToolFromWizard(config: AddMcpToolConfig): Promise { +export async function getRemovableGatewayTargets(): Promise { try { const configIO = new ConfigIO(); if (!configIO.configExists('mcp')) { return []; } const mcpSpec = await configIO.readMcpSpec(); - const tools: RemovableMcpTool[] = []; + const tools: RemovableGatewayTarget[] = []; // MCP Runtime tools for (const tool of mcpSpec.mcpRuntimeTools ?? []) { @@ -51,7 +51,7 @@ export async function getRemovableMcpTools(): Promise { /** * Compute the preview of what will be removed when removing an MCP tool. */ -export async function previewRemoveMcpTool(tool: RemovableMcpTool): Promise { +export async function previewRemoveGatewayTarget(tool: RemovableGatewayTarget): Promise { const configIO = new ConfigIO(); const mcpSpec = await configIO.readMcpSpec(); const mcpDefs = configIO.configExists('mcpDefs') ? await configIO.readMcpDefs() : { tools: {} }; @@ -139,7 +139,7 @@ export async function previewRemoveMcpTool(tool: RemovableMcpTool): Promise { +export async function removeGatewayTarget(tool: RemovableGatewayTarget): Promise { try { const configIO = new ConfigIO(); const mcpSpec = await configIO.readMcpSpec(); diff --git a/src/cli/templates/McpToolRenderer.ts b/src/cli/templates/GatewayTargetRenderer.ts similarity index 98% rename from src/cli/templates/McpToolRenderer.ts rename to src/cli/templates/GatewayTargetRenderer.ts index ad15d08f..12e25f4d 100644 --- a/src/cli/templates/McpToolRenderer.ts +++ b/src/cli/templates/GatewayTargetRenderer.ts @@ -68,7 +68,7 @@ export function getTemplateToolDefinitions(toolName: string, host: ComputeHost): * @param language - Target language ('Python' or 'TypeScript') * @param host - Compute host ('Lambda' or 'AgentCoreRuntime') */ -export async function renderMcpToolTemplate( +export async function renderGatewayTargetTemplate( toolName: string, outputDir: string, language: TargetLanguage, diff --git a/src/cli/templates/index.ts b/src/cli/templates/index.ts index f766e05d..fc892ce5 100644 --- a/src/cli/templates/index.ts +++ b/src/cli/templates/index.ts @@ -8,7 +8,7 @@ import type { AgentRenderConfig } from './types'; export { BaseRenderer, type RendererContext } from './BaseRenderer'; export { CDKRenderer, type CDKRendererContext } from './CDKRenderer'; -export { renderMcpToolTemplate } from './McpToolRenderer'; +export { renderGatewayTargetTemplate } from './GatewayTargetRenderer'; export { CrewAIRenderer } from './CrewAIRenderer'; export { GoogleADKRenderer } from './GoogleADKRenderer'; export { LangGraphRenderer } from './LangGraphRenderer'; diff --git a/src/cli/tui/hooks/__tests__/useRemove.test.tsx b/src/cli/tui/hooks/__tests__/useRemove.test.tsx index 4cae4b2a..41783806 100644 --- a/src/cli/tui/hooks/__tests__/useRemove.test.tsx +++ b/src/cli/tui/hooks/__tests__/useRemove.test.tsx @@ -20,17 +20,17 @@ const mockRemoveAgent = vi.fn(); vi.mock('../../../operations/remove', () => ({ getRemovableAgents: (...args: unknown[]) => mockGetRemovableAgents(...args), getRemovableGateways: (...args: unknown[]) => mockGetRemovableGateways(...args), - getRemovableMcpTools: vi.fn().mockResolvedValue([]), + getRemovableGatewayTargets: vi.fn().mockResolvedValue([]), getRemovableMemories: (...args: unknown[]) => mockGetRemovableMemories(...args), getRemovableIdentities: (...args: unknown[]) => mockGetRemovableIdentities(...args), previewRemoveAgent: vi.fn(), previewRemoveGateway: vi.fn(), - previewRemoveMcpTool: vi.fn(), + previewRemoveGatewayTarget: vi.fn(), previewRemoveMemory: vi.fn(), previewRemoveIdentity: vi.fn(), removeAgent: (...args: unknown[]) => mockRemoveAgent(...args), removeGateway: vi.fn(), - removeMcpTool: vi.fn(), + removeGatewayTarget: vi.fn(), removeMemory: vi.fn(), removeIdentity: vi.fn(), })); diff --git a/src/cli/tui/hooks/index.ts b/src/cli/tui/hooks/index.ts index b7f832e9..6138430f 100644 --- a/src/cli/tui/hooks/index.ts +++ b/src/cli/tui/hooks/index.ts @@ -6,7 +6,7 @@ export { useExitHandler } from './useExitHandler'; export { useListNavigation } from './useListNavigation'; export { useMultiSelectNavigation } from './useMultiSelectNavigation'; export { useResponsive } from './useResponsive'; -export { useAvailableAgents, useCreateGateway, useCreateMcpTool, useExistingGateways } from './useCreateMcp'; +export { useAvailableAgents, useCreateGateway, useCreateGatewayTarget, useExistingGateways } from './useCreateMcp'; export { useDevServer } from './useDevServer'; export { useProject } from './useProject'; export type { UseProjectResult, ProjectContext } from './useProject'; diff --git a/src/cli/tui/hooks/useCreateMcp.ts b/src/cli/tui/hooks/useCreateMcp.ts index 7d61d8f2..7d0971eb 100644 --- a/src/cli/tui/hooks/useCreateMcp.ts +++ b/src/cli/tui/hooks/useCreateMcp.ts @@ -6,7 +6,7 @@ import { getExistingGateways, getExistingToolNames, } from '../../operations/mcp/create-mcp'; -import type { AddGatewayConfig, AddMcpToolConfig } from '../screens/mcp/types'; +import type { AddGatewayConfig, AddGatewayTargetConfig } from '../screens/mcp/types'; import { useCallback, useEffect, useState } from 'react'; interface CreateStatus { @@ -38,10 +38,10 @@ export function useCreateGateway() { return { status, createGateway, reset }; } -export function useCreateMcpTool() { +export function useCreateGatewayTarget() { const [status, setStatus] = useState>({ state: 'idle' }); - const createTool = useCallback(async (config: AddMcpToolConfig) => { + const createTool = useCallback(async (config: AddGatewayTargetConfig) => { setStatus({ state: 'loading' }); try { const result = await createToolFromWizard(config); diff --git a/src/cli/tui/hooks/useRemove.ts b/src/cli/tui/hooks/useRemove.ts index f934110c..dccdb79c 100644 --- a/src/cli/tui/hooks/useRemove.ts +++ b/src/cli/tui/hooks/useRemove.ts @@ -1,26 +1,26 @@ import { RemoveLogger } from '../../logging'; import type { + RemovableGatewayTarget, RemovableIdentity, - RemovableMcpTool, RemovableMemory, RemovalPreview, RemovalResult, } from '../../operations/remove'; import { getRemovableAgents, + getRemovableGatewayTargets, getRemovableGateways, getRemovableIdentities, - getRemovableMcpTools, getRemovableMemories, previewRemoveAgent, previewRemoveGateway, + previewRemoveGatewayTarget, previewRemoveIdentity, - previewRemoveMcpTool, previewRemoveMemory, removeAgent, removeGateway, + removeGatewayTarget, removeIdentity, - removeMcpTool, removeMemory, } from '../../operations/remove'; import { useCallback, useEffect, useState } from 'react'; @@ -67,19 +67,19 @@ export function useRemovableGateways() { return { gateways: gateways ?? [], isLoading: gateways === null, refresh }; } -export function useRemovableMcpTools() { - const [tools, setTools] = useState(null); +export function useRemovableGatewayTargets() { + const [tools, setTools] = useState(null); useEffect(() => { async function load() { - const result = await getRemovableMcpTools(); + const result = await getRemovableGatewayTargets(); setTools(result); } void load(); }, []); const refresh = useCallback(async () => { - const result = await getRemovableMcpTools(); + const result = await getRemovableGatewayTargets(); setTools(result); }, []); @@ -167,10 +167,10 @@ export function useRemovalPreview() { } }, []); - const loadMcpToolPreview = useCallback(async (tool: RemovableMcpTool) => { + const loadGatewayTargetPreview = useCallback(async (tool: RemovableGatewayTarget) => { setState({ isLoading: true, preview: null, error: null }); try { - const preview = await previewRemoveMcpTool(tool); + const preview = await previewRemoveGatewayTarget(tool); setState({ isLoading: false, preview, error: null }); return { ok: true as const, preview }; } catch (err) { @@ -214,7 +214,7 @@ export function useRemovalPreview() { ...state, loadAgentPreview, loadGatewayPreview, - loadMcpToolPreview, + loadGatewayTargetPreview, loadMemoryPreview, loadIdentityPreview, reset, @@ -289,18 +289,18 @@ export function useRemoveGateway() { return { ...state, logFilePath, remove, reset }; } -export function useRemoveMcpTool() { +export function useRemoveGatewayTarget() { const [state, setState] = useState({ isLoading: false, result: null }); const [logFilePath, setLogFilePath] = useState(null); - const remove = useCallback(async (tool: RemovableMcpTool, preview?: RemovalPreview): Promise => { + const remove = useCallback(async (tool: RemovableGatewayTarget, preview?: RemovalPreview): Promise => { setState({ isLoading: true, result: null }); - const result = await removeMcpTool(tool); + const result = await removeGatewayTarget(tool); setState({ isLoading: false, result }); let logPath: string | undefined; if (preview) { - const logger = new RemoveLogger({ resourceType: 'mcp-tool', resourceName: tool.name }); + const logger = new RemoveLogger({ resourceType: 'gateway-target', resourceName: tool.name }); logger.logRemoval(preview, result.ok, result.ok ? undefined : result.error); logPath = logger.getAbsoluteLogPath(); setLogFilePath(logPath); diff --git a/src/cli/tui/screens/add/AddFlow.tsx b/src/cli/tui/screens/add/AddFlow.tsx index 236976f3..921da218 100644 --- a/src/cli/tui/screens/add/AddFlow.tsx +++ b/src/cli/tui/screens/add/AddFlow.tsx @@ -7,7 +7,7 @@ import type { AddAgentConfig } from '../agent/types'; import { FRAMEWORK_OPTIONS } from '../agent/types'; import { useAddAgent } from '../agent/useAddAgent'; import { AddIdentityFlow } from '../identity'; -import { AddGatewayFlow, AddMcpToolFlow } from '../mcp'; +import { AddGatewayFlow, AddGatewayTargetFlow } from '../mcp'; import { AddMemoryFlow } from '../memory/AddMemoryFlow'; import type { AddResourceType } from './AddScreen'; import { AddScreen } from './AddScreen'; @@ -163,7 +163,7 @@ export function AddFlow(props: AddFlowProps) { case 'gateway': setFlow({ name: 'gateway-wizard' }); break; - case 'mcp-tool': + case 'gateway-target': setFlow({ name: 'tool-wizard' }); break; case 'memory': @@ -334,10 +334,10 @@ export function AddFlow(props: AddFlowProps) { ); } - // MCP Tool wizard - now uses AddMcpToolFlow with mode selection + // MCP Tool wizard - now uses AddGatewayTargetFlow with mode selection if (flow.name === 'tool-wizard') { return ( - ({ name: 'mode-select' }); @@ -106,7 +106,7 @@ export function AddMcpToolFlow({ }); const handleCreateComplete = useCallback( - (config: AddMcpToolConfig) => { + (config: AddGatewayTargetConfig) => { setFlow({ name: 'create-success', toolName: config.name, @@ -152,7 +152,7 @@ export function AddMcpToolFlow({ // If no MCP runtimes exist to bind, skip to create if (!hasRuntimesToBind) { return ( - void; + onComplete: (config: AddGatewayTargetConfig) => void; onExit: () => void; } -export function AddMcpToolScreen({ +export function AddGatewayTargetScreen({ existingGateways, existingAgents, existingToolNames, onComplete, onExit, -}: AddMcpToolScreenProps) { - const wizard = useAddMcpToolWizard(existingGateways, existingAgents); +}: AddGatewayTargetScreenProps) { + const wizard = useAddGatewayTargetWizard(existingGateways, existingAgents); const languageItems: SelectableItem[] = useMemo( () => TARGET_LANGUAGE_OPTIONS.map(o => ({ id: o.id, title: o.title, description: o.description })), diff --git a/src/cli/tui/screens/mcp/index.ts b/src/cli/tui/screens/mcp/index.ts index 06818162..c7e6956a 100644 --- a/src/cli/tui/screens/mcp/index.ts +++ b/src/cli/tui/screens/mcp/index.ts @@ -1,14 +1,14 @@ export { AddGatewayFlow } from './AddGatewayFlow'; export { AddGatewayScreen } from './AddGatewayScreen'; -export { AddMcpToolFlow } from './AddMcpToolFlow'; -export { AddMcpToolScreen } from './AddMcpToolScreen'; +export { AddGatewayTargetFlow } from './AddGatewayTargetFlow'; +export { AddGatewayTargetScreen } from './AddGatewayTargetScreen'; export { useAddGatewayWizard } from './useAddGatewayWizard'; -export { useAddMcpToolWizard } from './useAddMcpWizard'; +export { useAddGatewayTargetWizard } from './useAddGatewayTargetWizard'; export type { AddGatewayConfig, AddGatewayStep, - AddMcpToolConfig, - AddMcpToolStep, + AddGatewayTargetConfig, + AddGatewayTargetStep, ComputeHost, ExposureMode, } from './types'; diff --git a/src/cli/tui/screens/mcp/types.ts b/src/cli/tui/screens/mcp/types.ts index 46fe7600..89b74bc5 100644 --- a/src/cli/tui/screens/mcp/types.ts +++ b/src/cli/tui/screens/mcp/types.ts @@ -47,11 +47,11 @@ export type ComputeHost = 'Lambda' | 'AgentCoreRuntime'; * - host: Select compute host (only if behind-gateway) * - confirm: Review and confirm */ -export type AddMcpToolStep = 'name' | 'language' | 'exposure' | 'agents' | 'gateway' | 'host' | 'confirm'; +export type AddGatewayTargetStep = 'name' | 'language' | 'exposure' | 'agents' | 'gateway' | 'host' | 'confirm'; export type TargetLanguage = 'Python' | 'TypeScript' | 'Other'; -export interface AddMcpToolConfig { +export interface AddGatewayTargetConfig { name: string; description: string; sourcePath: string; @@ -67,7 +67,7 @@ export interface AddMcpToolConfig { selectedAgents: string[]; } -export const MCP_TOOL_STEP_LABELS: Record = { +export const MCP_TOOL_STEP_LABELS: Record = { name: 'Name', language: 'Language', exposure: 'Exposure', diff --git a/src/cli/tui/screens/mcp/useAddMcpWizard.ts b/src/cli/tui/screens/mcp/useAddGatewayTargetWizard.ts similarity index 86% rename from src/cli/tui/screens/mcp/useAddMcpWizard.ts rename to src/cli/tui/screens/mcp/useAddGatewayTargetWizard.ts index 4c32186d..2deda24e 100644 --- a/src/cli/tui/screens/mcp/useAddMcpWizard.ts +++ b/src/cli/tui/screens/mcp/useAddGatewayTargetWizard.ts @@ -1,6 +1,6 @@ import { APP_DIR, MCP_APP_SUBDIR } from '../../../../lib'; import type { ToolDefinition } from '../../../../schema'; -import type { AddMcpToolConfig, AddMcpToolStep, ComputeHost, ExposureMode, TargetLanguage } from './types'; +import type { AddGatewayTargetConfig, AddGatewayTargetStep, ComputeHost, ExposureMode, TargetLanguage } from './types'; import { useCallback, useMemo, useState } from 'react'; /** @@ -8,7 +8,7 @@ import { useCallback, useMemo, useState } from 'react'; * - MCP Runtime: name → language → exposure → agents → confirm * - Behind gateway: name → language → exposure → gateway → host → confirm */ -function getSteps(exposure: ExposureMode): AddMcpToolStep[] { +function getSteps(exposure: ExposureMode): AddGatewayTargetStep[] { if (exposure === 'mcp-runtime') { return ['name', 'language', 'exposure', 'agents', 'confirm']; } @@ -23,7 +23,7 @@ function deriveToolDefinition(name: string): ToolDefinition { }; } -function getDefaultConfig(): AddMcpToolConfig { +function getDefaultConfig(): AddGatewayTargetConfig { return { name: '', description: '', @@ -36,9 +36,9 @@ function getDefaultConfig(): AddMcpToolConfig { }; } -export function useAddMcpToolWizard(existingGateways: string[] = [], existingAgents: string[] = []) { - const [config, setConfig] = useState(getDefaultConfig); - const [step, setStep] = useState('name'); +export function useAddGatewayTargetWizard(existingGateways: string[] = [], existingAgents: string[] = []) { + const [config, setConfig] = useState(getDefaultConfig); + const [step, setStep] = useState('name'); const steps = useMemo(() => getSteps(config.exposure), [config.exposure]); const currentIndex = steps.indexOf(step); diff --git a/src/cli/tui/screens/remove/RemoveFlow.tsx b/src/cli/tui/screens/remove/RemoveFlow.tsx index e2aed558..5f299cb5 100644 --- a/src/cli/tui/screens/remove/RemoveFlow.tsx +++ b/src/cli/tui/screens/remove/RemoveFlow.tsx @@ -1,24 +1,24 @@ -import type { RemovableMcpTool, RemovalPreview } from '../../../operations/remove'; +import type { RemovableGatewayTarget, RemovalPreview } from '../../../operations/remove'; import { ErrorPrompt, Panel, Screen } from '../../components'; import { useRemovableAgents, + useRemovableGatewayTargets, useRemovableGateways, useRemovableIdentities, - useRemovableMcpTools, useRemovableMemories, useRemovalPreview, useRemoveAgent, useRemoveGateway, + useRemoveGatewayTarget, useRemoveIdentity, - useRemoveMcpTool, useRemoveMemory, } from '../../hooks/useRemove'; import { RemoveAgentScreen } from './RemoveAgentScreen'; import { RemoveAllScreen } from './RemoveAllScreen'; import { RemoveConfirmScreen } from './RemoveConfirmScreen'; import { RemoveGatewayScreen } from './RemoveGatewayScreen'; +import { RemoveGatewayTargetScreen } from './RemoveGatewayTargetScreen'; import { RemoveIdentityScreen } from './RemoveIdentityScreen'; -import { RemoveMcpToolScreen } from './RemoveMcpToolScreen'; import { RemoveMemoryScreen } from './RemoveMemoryScreen'; import type { RemoveResourceType } from './RemoveScreen'; import { RemoveScreen } from './RemoveScreen'; @@ -31,12 +31,12 @@ type FlowState = | { name: 'select' } | { name: 'select-agent' } | { name: 'select-gateway' } - | { name: 'select-mcp-tool' } + | { name: 'select-gateway-target' } | { name: 'select-memory' } | { name: 'select-identity' } | { name: 'confirm-agent'; agentName: string; preview: RemovalPreview } | { name: 'confirm-gateway'; gatewayName: string; preview: RemovalPreview } - | { name: 'confirm-mcp-tool'; tool: RemovableMcpTool; preview: RemovalPreview } + | { name: 'confirm-gateway-target'; tool: RemovableGatewayTarget; preview: RemovalPreview } | { name: 'confirm-memory'; memoryName: string; preview: RemovalPreview } | { name: 'confirm-identity'; identityName: string; preview: RemovalPreview } | { name: 'loading'; message: string } @@ -57,7 +57,7 @@ interface RemoveFlowProps { /** Force mode - skip confirmation */ force?: boolean; /** Initial resource type to start at (for CLI subcommands) */ - initialResourceType?: 'agent' | 'gateway' | 'mcp-tool' | 'memory' | 'identity'; + initialResourceType?: 'agent' | 'gateway' | 'gateway-target' | 'memory' | 'identity'; /** Initial resource name to auto-select (for CLI --name flag) */ initialResourceName?: string; } @@ -77,8 +77,8 @@ export function RemoveFlow({ return { name: 'select-agent' }; case 'gateway': return { name: 'select-gateway' }; - case 'mcp-tool': - return { name: 'select-mcp-tool' }; + case 'gateway-target': + return { name: 'select-gateway-target' }; case 'memory': return { name: 'select-memory' }; case 'identity': @@ -92,7 +92,7 @@ export function RemoveFlow({ // Data hooks - need isLoading to avoid showing screen before data loads const { agents, isLoading: isLoadingAgents, refresh: refreshAgents } = useRemovableAgents(); const { gateways, isLoading: isLoadingGateways, refresh: refreshGateways } = useRemovableGateways(); - const { tools: mcpTools, isLoading: isLoadingTools, refresh: refreshTools } = useRemovableMcpTools(); + const { tools: mcpTools, isLoading: isLoadingTools, refresh: refreshTools } = useRemovableGatewayTargets(); const { memories, isLoading: isLoadingMemories, refresh: refreshMemories } = useRemovableMemories(); const { identities, isLoading: isLoadingIdentities, refresh: refreshIdentities } = useRemovableIdentities(); @@ -103,7 +103,7 @@ export function RemoveFlow({ const { loadAgentPreview, loadGatewayPreview, - loadMcpToolPreview, + loadGatewayTargetPreview, loadMemoryPreview, loadIdentityPreview, reset: resetPreview, @@ -112,7 +112,7 @@ export function RemoveFlow({ // Removal hooks const { remove: removeAgentOp, reset: resetRemoveAgent } = useRemoveAgent(); const { remove: removeGatewayOp, reset: resetRemoveGateway } = useRemoveGateway(); - const { remove: removeMcpToolOp, reset: resetRemoveMcpTool } = useRemoveMcpTool(); + const { remove: removeGatewayTargetOp, reset: resetRemoveGatewayTarget } = useRemoveGatewayTarget(); const { remove: removeMemoryOp, reset: resetRemoveMemory } = useRemoveMemory(); const { remove: removeIdentityOp, reset: resetRemoveIdentity } = useRemoveIdentity(); @@ -153,8 +153,8 @@ export function RemoveFlow({ case 'gateway': setFlow({ name: 'select-gateway' }); break; - case 'mcp-tool': - setFlow({ name: 'select-mcp-tool' }); + case 'gateway-target': + setFlow({ name: 'select-gateway-target' }); break; case 'memory': setFlow({ name: 'select-memory' }); @@ -215,26 +215,26 @@ export function RemoveFlow({ [loadGatewayPreview, force, removeGatewayOp] ); - const handleSelectMcpTool = useCallback( - async (tool: RemovableMcpTool) => { - const result = await loadMcpToolPreview(tool); + const handleSelectGatewayTarget = useCallback( + async (tool: RemovableGatewayTarget) => { + const result = await loadGatewayTargetPreview(tool); if (result.ok) { if (force) { setFlow({ name: 'loading', message: `Removing MCP tool ${tool.name}...` }); - const removeResult = await removeMcpToolOp(tool, result.preview); + const removeResult = await removeGatewayTargetOp(tool, result.preview); if (removeResult.ok) { setFlow({ name: 'tool-success', toolName: tool.name }); } else { setFlow({ name: 'error', message: removeResult.error }); } } else { - setFlow({ name: 'confirm-mcp-tool', tool, preview: result.preview }); + setFlow({ name: 'confirm-gateway-target', tool, preview: result.preview }); } } else { setFlow({ name: 'error', message: result.error }); } }, - [loadMcpToolPreview, force, removeMcpToolOp] + [loadGatewayTargetPreview, force, removeGatewayTargetOp] ); const handleSelectMemory = useCallback( @@ -350,12 +350,12 @@ export function RemoveFlow({ [removeGatewayOp] ); - const handleConfirmMcpTool = useCallback( - async (tool: RemovableMcpTool, preview: RemovalPreview) => { + const handleConfirmGatewayTarget = useCallback( + async (tool: RemovableGatewayTarget, preview: RemovalPreview) => { pendingResultRef.current = null; setResultReady(false); setFlow({ name: 'loading', message: `Removing MCP tool ${tool.name}...` }); - const result = await removeMcpToolOp(tool, preview); + const result = await removeGatewayTargetOp(tool, preview); if (result.ok) { pendingResultRef.current = { name: 'tool-success', toolName: tool.name, logFilePath: result.logFilePath }; } else { @@ -363,7 +363,7 @@ export function RemoveFlow({ } setResultReady(true); }, - [removeMcpToolOp] + [removeGatewayTargetOp] ); const handleConfirmMemory = useCallback( @@ -402,10 +402,17 @@ export function RemoveFlow({ resetPreview(); resetRemoveAgent(); resetRemoveGateway(); - resetRemoveMcpTool(); + resetRemoveGatewayTarget(); resetRemoveMemory(); resetRemoveIdentity(); - }, [resetPreview, resetRemoveAgent, resetRemoveGateway, resetRemoveMcpTool, resetRemoveMemory, resetRemoveIdentity]); + }, [ + resetPreview, + resetRemoveAgent, + resetRemoveGateway, + resetRemoveGatewayTarget, + resetRemoveMemory, + resetRemoveIdentity, + ]); const refreshAll = useCallback(async () => { await Promise.all([refreshAgents(), refreshGateways(), refreshTools(), refreshMemories(), refreshIdentities()]); @@ -471,11 +478,11 @@ export function RemoveFlow({ ); } - if (flow.name === 'select-mcp-tool') { + if (flow.name === 'select-gateway-target') { return ( - void handleSelectMcpTool(tool)} + onSelect={(tool: RemovableGatewayTarget) => void handleSelectGatewayTarget(tool)} onExit={() => setFlow({ name: 'select' })} /> ); @@ -530,13 +537,13 @@ export function RemoveFlow({ ); } - if (flow.name === 'confirm-mcp-tool') { + if (flow.name === 'confirm-gateway-target') { return ( void handleConfirmMcpTool(flow.tool, flow.preview)} - onCancel={() => setFlow({ name: 'select-mcp-tool' })} + onConfirm={() => void handleConfirmGatewayTarget(flow.tool, flow.preview)} + onCancel={() => setFlow({ name: 'select-gateway-target' })} /> ); } diff --git a/src/cli/tui/screens/remove/RemoveMcpToolScreen.tsx b/src/cli/tui/screens/remove/RemoveGatewayTargetScreen.tsx similarity index 71% rename from src/cli/tui/screens/remove/RemoveMcpToolScreen.tsx rename to src/cli/tui/screens/remove/RemoveGatewayTargetScreen.tsx index eab5fc67..e0253ddb 100644 --- a/src/cli/tui/screens/remove/RemoveMcpToolScreen.tsx +++ b/src/cli/tui/screens/remove/RemoveGatewayTargetScreen.tsx @@ -1,17 +1,17 @@ -import type { RemovableMcpTool } from '../../../operations/remove'; +import type { RemovableGatewayTarget } from '../../../operations/remove'; import { SelectScreen } from '../../components'; import React from 'react'; -interface RemoveMcpToolScreenProps { +interface RemoveGatewayTargetScreenProps { /** List of MCP tools that can be removed */ - tools: RemovableMcpTool[]; + tools: RemovableGatewayTarget[]; /** Called when a tool is selected for removal */ - onSelect: (tool: RemovableMcpTool) => void; + onSelect: (tool: RemovableGatewayTarget) => void; /** Called when user cancels */ onExit: () => void; } -export function RemoveMcpToolScreen({ tools, onSelect, onExit }: RemoveMcpToolScreenProps) { +export function RemoveGatewayTargetScreen({ tools, onSelect, onExit }: RemoveGatewayTargetScreenProps) { const items = tools.map(tool => ({ id: tool.name, title: tool.name, diff --git a/src/cli/tui/screens/remove/RemoveScreen.tsx b/src/cli/tui/screens/remove/RemoveScreen.tsx index 92ff2356..fe140276 100644 --- a/src/cli/tui/screens/remove/RemoveScreen.tsx +++ b/src/cli/tui/screens/remove/RemoveScreen.tsx @@ -7,7 +7,7 @@ const REMOVE_RESOURCES = [ { id: 'memory', title: 'Memory', description: 'Remove a memory provider' }, { id: 'identity', title: 'Identity', description: 'Remove an identity provider' }, { id: 'gateway', title: 'Gateway (coming soon)', description: 'Remove an MCP gateway', disabled: true }, - { id: 'mcp-tool', title: 'MCP Tool (coming soon)', description: 'Remove an MCP tool', disabled: true }, + { id: 'gateway-target', title: 'MCP Tool (coming soon)', description: 'Remove an MCP tool', disabled: true }, { id: 'all', title: 'All', description: 'Reset entire agentcore project' }, ] as const; diff --git a/src/cli/tui/screens/remove/index.ts b/src/cli/tui/screens/remove/index.ts index d8c46d47..71d78c30 100644 --- a/src/cli/tui/screens/remove/index.ts +++ b/src/cli/tui/screens/remove/index.ts @@ -4,7 +4,7 @@ export { RemoveConfirmScreen } from './RemoveConfirmScreen'; export { RemoveFlow } from './RemoveFlow'; export { RemoveGatewayScreen } from './RemoveGatewayScreen'; export { RemoveIdentityScreen } from './RemoveIdentityScreen'; -export { RemoveMcpToolScreen } from './RemoveMcpToolScreen'; +export { RemoveGatewayTargetScreen } from './RemoveGatewayTargetScreen'; export { RemoveMemoryScreen } from './RemoveMemoryScreen'; export { RemoveScreen, type RemoveResourceType } from './RemoveScreen'; export { RemoveSuccessScreen } from './RemoveSuccessScreen'; diff --git a/src/cli/tui/screens/schema/McpGuidedEditor.tsx b/src/cli/tui/screens/schema/McpGuidedEditor.tsx index f87693e3..f8c0b74b 100644 --- a/src/cli/tui/screens/schema/McpGuidedEditor.tsx +++ b/src/cli/tui/screens/schema/McpGuidedEditor.tsx @@ -198,7 +198,7 @@ function McpEditorBody(props: { return; } if (key.return && targets.length > 0) { - setEditingTargetFieldId('toolName'); + setEditingTargetFieldId('targetName'); setScreenMode('edit-target-field'); return; } diff --git a/src/cli/tui/utils/__tests__/commands.test.ts b/src/cli/tui/utils/__tests__/commands.test.ts index d4753638..46ac7319 100644 --- a/src/cli/tui/utils/__tests__/commands.test.ts +++ b/src/cli/tui/utils/__tests__/commands.test.ts @@ -22,7 +22,7 @@ function makeProgram(cmds: Command[]) { describe('getCommandsForUI', () => { const program = makeProgram([ makeCmd('create', 'Create a new project'), - makeCmd('add', 'Add a resource', ['agent', 'memory', 'gateway', 'mcp-tool']), + makeCmd('add', 'Add a resource', ['agent', 'memory', 'gateway', 'gateway-target']), makeCmd('deploy', 'Deploy to AWS'), makeCmd('status', 'Check status'), makeCmd('help', 'Show help'), @@ -60,14 +60,14 @@ describe('getCommandsForUI', () => { expect(names).toContain('create'); }); - it('filters hidden subcommands (gateway, mcp-tool)', () => { + it('filters hidden subcommands (gateway, gateway-target)', () => { const cmds = getCommandsForUI(program); const addCmd = cmds.find(c => c.id === 'add'); expect(addCmd).toBeDefined(); expect(addCmd!.subcommands).toContain('agent'); expect(addCmd!.subcommands).toContain('memory'); expect(addCmd!.subcommands).not.toContain('gateway'); - expect(addCmd!.subcommands).not.toContain('mcp-tool'); + expect(addCmd!.subcommands).not.toContain('gateway-target'); }); it('returns command metadata shape', () => { diff --git a/src/cli/tui/utils/commands.ts b/src/cli/tui/utils/commands.ts index 13c4d820..874a08af 100644 --- a/src/cli/tui/utils/commands.ts +++ b/src/cli/tui/utils/commands.ts @@ -24,7 +24,7 @@ const HIDDEN_WHEN_IN_PROJECT = ['create'] as const; * These are registered with { hidden: true } in commander but we track them * here since commander doesn't expose a public API to check hidden status. */ -const HIDDEN_SUBCOMMANDS = ['gateway', 'mcp-tool'] as const; +const HIDDEN_SUBCOMMANDS = ['gateway', 'gateway-target'] as const; interface GetCommandsOptions { /** Whether user is currently inside an AgentCore project */ From 1baab37a7893e069a50f3c4da370d55ceb456779 Mon Sep 17 00:00:00 2001 From: Aidan Daly <99039782+aidandaly24@users.noreply.github.com> Date: Mon, 23 Feb 2026 12:35:37 -0500 Subject: [PATCH 03/28] feat: enable gateway commands, UI updates, deploy pipeline, credential validation (#382) --- .../assets.snapshot.test.ts.snap | 41 ++++++- src/assets/cdk/bin/cdk.ts | 14 +++ src/assets/cdk/lib/cdk-stack.ts | 27 ++++- .../__tests__/outputs-extended.test.ts | 12 +- .../cloudformation/__tests__/outputs.test.ts | 4 +- src/cli/cloudformation/outputs.ts | 49 +++++++++ .../commands/add/__tests__/validate.test.ts | 35 +++--- src/cli/commands/add/actions.ts | 13 +++ src/cli/commands/add/command.tsx | 28 +++-- src/cli/commands/add/types.ts | 2 + src/cli/commands/add/validate.ts | 58 ++++++++-- src/cli/commands/deploy/actions.ts | 60 ++++++++-- src/cli/commands/remove/command.tsx | 24 +--- src/cli/operations/deploy/preflight.ts | 21 +++- src/cli/operations/mcp/create-mcp.ts | 27 +++++ src/cli/operations/remove/remove-identity.ts | 47 ++++++++ src/cli/tui/components/ResourceGraph.tsx | 40 +++++-- .../__tests__/ResourceGraph.test.tsx | 4 +- src/cli/tui/screens/add/AddScreen.tsx | 6 +- src/cli/tui/screens/deploy/useDeployFlow.ts | 11 +- src/cli/tui/screens/mcp/AddGatewayScreen.tsx | 19 +++- .../screens/mcp/AddGatewayTargetScreen.tsx | 2 +- src/cli/tui/screens/mcp/types.ts | 12 +- src/cli/tui/screens/remove/RemoveScreen.tsx | 26 +++-- .../tui/screens/schema/McpGuidedEditor.tsx | 104 +++++++++++++++--- 25 files changed, 553 insertions(+), 133 deletions(-) diff --git a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap index 8de5fc00..af1a1695 100644 --- a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap +++ b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap @@ -48,6 +48,7 @@ import { AgentCoreStack } from '../lib/cdk-stack'; import { ConfigIO, type AwsDeploymentTarget } from '@aws/agentcore-cdk'; import { App, type Environment } from 'aws-cdk-lib'; import * as path from 'path'; +import * as fs from 'fs'; function toEnvironment(target: AwsDeploymentTarget): Environment { return { @@ -72,6 +73,17 @@ async function main() { const spec = await configIO.readProjectSpec(); const targets = await configIO.readAWSDeploymentTargets(); + // Read MCP configuration if it exists + let mcpSpec; + let mcpDeployedState; + try { + mcpSpec = await configIO.readMcpSpec(); + const deployedState = JSON.parse(fs.readFileSync(path.join(configRoot, '.cli', 'deployed-state.json'), 'utf8')); + mcpDeployedState = deployedState?.mcp; + } catch { + // MCP config is optional + } + if (targets.length === 0) { throw new Error('No deployment targets configured. Please define targets in agentcore/aws-targets.json'); } @@ -84,6 +96,8 @@ async function main() { new AgentCoreStack(app, stackName, { spec, + mcpSpec, + mcpDeployedState, env, description: \`AgentCore stack for \${spec.name} deployed to \${target.name} (\${target.region})\`, tags: { @@ -222,7 +236,13 @@ exports[`Assets Directory Snapshots > CDK assets > cdk/cdk/jest.config.js should `; exports[`Assets Directory Snapshots > CDK assets > cdk/cdk/lib/cdk-stack.ts should match snapshot 1`] = ` -"import { AgentCoreApplication, type AgentCoreProjectSpec } from '@aws/agentcore-cdk'; +"import { + AgentCoreApplication, + AgentCoreMcp, + type AgentCoreProjectSpec, + type McpSpec, + type McpDeployedState, +} from '@aws/agentcore-cdk'; import { CfnOutput, Stack, type StackProps } from 'aws-cdk-lib'; import { Construct } from 'constructs'; @@ -231,6 +251,14 @@ export interface AgentCoreStackProps extends StackProps { * The AgentCore project specification containing agents, memories, and credentials. */ spec: AgentCoreProjectSpec; + /** + * The MCP specification containing gateways and servers. + */ + mcpSpec?: McpSpec; + /** + * The MCP deployed state. + */ + mcpDeployedState?: McpDeployedState; } /** @@ -246,13 +274,22 @@ export class AgentCoreStack extends Stack { constructor(scope: Construct, id: string, props: AgentCoreStackProps) { super(scope, id, props); - const { spec } = props; + const { spec, mcpSpec, mcpDeployedState } = props; // Create AgentCoreApplication with all agents this.application = new AgentCoreApplication(this, 'Application', { spec, }); + // Create AgentCoreMcp if there are gateways configured + if (mcpSpec?.agentCoreGateways && mcpSpec.agentCoreGateways.length > 0) { + new AgentCoreMcp(this, 'Mcp', { + spec: mcpSpec, + deployedState: mcpDeployedState, + application: this.application, + }); + } + // Stack-level output new CfnOutput(this, 'StackNameOutput', { description: 'Name of the CloudFormation Stack', diff --git a/src/assets/cdk/bin/cdk.ts b/src/assets/cdk/bin/cdk.ts index 9b2ead1c..3c62245f 100644 --- a/src/assets/cdk/bin/cdk.ts +++ b/src/assets/cdk/bin/cdk.ts @@ -3,6 +3,7 @@ import { AgentCoreStack } from '../lib/cdk-stack'; import { ConfigIO, type AwsDeploymentTarget } from '@aws/agentcore-cdk'; import { App, type Environment } from 'aws-cdk-lib'; import * as path from 'path'; +import * as fs from 'fs'; function toEnvironment(target: AwsDeploymentTarget): Environment { return { @@ -27,6 +28,17 @@ async function main() { const spec = await configIO.readProjectSpec(); const targets = await configIO.readAWSDeploymentTargets(); + // Read MCP configuration if it exists + let mcpSpec; + let mcpDeployedState; + try { + mcpSpec = await configIO.readMcpSpec(); + const deployedState = JSON.parse(fs.readFileSync(path.join(configRoot, '.cli', 'deployed-state.json'), 'utf8')); + mcpDeployedState = deployedState?.mcp; + } catch { + // MCP config is optional + } + if (targets.length === 0) { throw new Error('No deployment targets configured. Please define targets in agentcore/aws-targets.json'); } @@ -39,6 +51,8 @@ async function main() { new AgentCoreStack(app, stackName, { spec, + mcpSpec, + mcpDeployedState, env, description: `AgentCore stack for ${spec.name} deployed to ${target.name} (${target.region})`, tags: { diff --git a/src/assets/cdk/lib/cdk-stack.ts b/src/assets/cdk/lib/cdk-stack.ts index 051ad235..fbff1465 100644 --- a/src/assets/cdk/lib/cdk-stack.ts +++ b/src/assets/cdk/lib/cdk-stack.ts @@ -1,4 +1,10 @@ -import { AgentCoreApplication, type AgentCoreProjectSpec } from '@aws/agentcore-cdk'; +import { + AgentCoreApplication, + AgentCoreMcp, + type AgentCoreProjectSpec, + type McpSpec, + type McpDeployedState, +} from '@aws/agentcore-cdk'; import { CfnOutput, Stack, type StackProps } from 'aws-cdk-lib'; import { Construct } from 'constructs'; @@ -7,6 +13,14 @@ export interface AgentCoreStackProps extends StackProps { * The AgentCore project specification containing agents, memories, and credentials. */ spec: AgentCoreProjectSpec; + /** + * The MCP specification containing gateways and servers. + */ + mcpSpec?: McpSpec; + /** + * The MCP deployed state. + */ + mcpDeployedState?: McpDeployedState; } /** @@ -22,13 +36,22 @@ export class AgentCoreStack extends Stack { constructor(scope: Construct, id: string, props: AgentCoreStackProps) { super(scope, id, props); - const { spec } = props; + const { spec, mcpSpec, mcpDeployedState } = props; // Create AgentCoreApplication with all agents this.application = new AgentCoreApplication(this, 'Application', { spec, }); + // Create AgentCoreMcp if there are gateways configured + if (mcpSpec?.agentCoreGateways && mcpSpec.agentCoreGateways.length > 0) { + new AgentCoreMcp(this, 'Mcp', { + spec: mcpSpec, + deployedState: mcpDeployedState, + application: this.application, + }); + } + // Stack-level output new CfnOutput(this, 'StackNameOutput', { description: 'Name of the CloudFormation Stack', diff --git a/src/cli/cloudformation/__tests__/outputs-extended.test.ts b/src/cli/cloudformation/__tests__/outputs-extended.test.ts index d6a64226..be2672da 100644 --- a/src/cli/cloudformation/__tests__/outputs-extended.test.ts +++ b/src/cli/cloudformation/__tests__/outputs-extended.test.ts @@ -157,7 +157,7 @@ describe('buildDeployedState', () => { }, }; - const state = buildDeployedState('default', 'MyStack', agents); + const state = buildDeployedState('default', 'MyStack', agents, {}); expect(state.targets.default).toBeDefined(); expect(state.targets.default!.resources?.agents).toEqual(agents); expect(state.targets.default!.resources?.stackName).toBe('MyStack'); @@ -181,7 +181,7 @@ describe('buildDeployedState', () => { DevAgent: { runtimeId: 'rt-d', runtimeArn: 'arn:rt-d', roleArn: 'arn:role-d' }, }; - const state = buildDeployedState('dev', 'DevStack', devAgents, existing); + const state = buildDeployedState('dev', 'DevStack', devAgents, {}, existing); expect(state.targets.prod).toBeDefined(); expect(state.targets.dev).toBeDefined(); expect(state.targets.prod!.resources?.stackName).toBe('ProdStack'); @@ -197,22 +197,22 @@ describe('buildDeployedState', () => { }, }; - const state = buildDeployedState('default', 'NewStack', {}, existing); + const state = buildDeployedState('default', 'NewStack', {}, {}, existing); expect(state.targets.default!.resources?.stackName).toBe('NewStack'); }); it('includes identityKmsKeyArn when provided', () => { - const state = buildDeployedState('default', 'Stack', {}, undefined, 'arn:aws:kms:key'); + const state = buildDeployedState('default', 'Stack', {}, {}, undefined, 'arn:aws:kms:key'); expect(state.targets.default!.resources?.identityKmsKeyArn).toBe('arn:aws:kms:key'); }); it('omits identityKmsKeyArn when undefined', () => { - const state = buildDeployedState('default', 'Stack', {}); + const state = buildDeployedState('default', 'Stack', {}, {}); expect(state.targets.default!.resources?.identityKmsKeyArn).toBeUndefined(); }); it('handles empty agents record', () => { - const state = buildDeployedState('default', 'Stack', {}); + const state = buildDeployedState('default', 'Stack', {}, {}); expect(state.targets.default!.resources?.agents).toEqual({}); }); }); diff --git a/src/cli/cloudformation/__tests__/outputs.test.ts b/src/cli/cloudformation/__tests__/outputs.test.ts index 1589844d..ce85fdcf 100644 --- a/src/cli/cloudformation/__tests__/outputs.test.ts +++ b/src/cli/cloudformation/__tests__/outputs.test.ts @@ -15,6 +15,7 @@ describe('buildDeployedState', () => { 'default', 'TestStack', agents, + {}, undefined, 'arn:aws:kms:us-east-1:123456789012:key/abc-123' ); @@ -31,7 +32,7 @@ describe('buildDeployedState', () => { }, }; - const result = buildDeployedState('default', 'TestStack', agents); + const result = buildDeployedState('default', 'TestStack', agents, {}); expect(result.targets.default!.resources?.identityKmsKeyArn).toBeUndefined(); }); @@ -52,6 +53,7 @@ describe('buildDeployedState', () => { 'dev', 'DevStack', {}, + {}, existingState, 'arn:aws:kms:us-east-1:123456789012:key/dev-key' ); diff --git a/src/cli/cloudformation/outputs.ts b/src/cli/cloudformation/outputs.ts index 6b8624b5..d10aa154 100644 --- a/src/cli/cloudformation/outputs.ts +++ b/src/cli/cloudformation/outputs.ts @@ -26,6 +26,47 @@ export async function getStackOutputs(region: string, stackName: string): Promis return outputs; } +/** + * Parse stack outputs into deployed state for gateways. + * + * Output key pattern for gateways: + * Gateway{GatewayName}UrlOutput{Hash} + * + * Examples: + * - GatewayMyGatewayUrlOutput3E11FAB4 + */ +export function parseGatewayOutputs( + outputs: StackOutputs, + gatewaySpecs: Record +): Record { + const gateways: Record = {}; + + // Map PascalCase gateway names to original names for lookup + const gatewayNames = Object.keys(gatewaySpecs); + const gatewayIdMap = new Map(gatewayNames.map(name => [toPascalId(name), name])); + + // Match pattern: Gateway{GatewayName}UrlOutput + const outputPattern = /^Gateway(.+?)UrlOutput/; + + for (const [key, value] of Object.entries(outputs)) { + const match = outputPattern.exec(key); + if (!match) continue; + + const logicalGateway = match[1]; + if (!logicalGateway) continue; + + // Look up original gateway name from PascalCase version + const gatewayName = gatewayIdMap.get(logicalGateway) ?? logicalGateway; + + gateways[gatewayName] = { + gatewayId: gatewayName, + gatewayArn: value, + }; + } + + return gateways; +} + /** * Parse stack outputs into deployed state for agents. * @@ -132,6 +173,7 @@ export function buildDeployedState( targetName: string, stackName: string, agents: Record, + gateways: Record, existingState?: DeployedState, identityKmsKeyArn?: string ): DeployedState { @@ -143,6 +185,13 @@ export function buildDeployedState( }, }; + // Add MCP state if gateways exist + if (Object.keys(gateways).length > 0) { + targetState.resources!.mcp = { + gateways, + }; + } + return { targets: { ...existingState?.targets, diff --git a/src/cli/commands/add/__tests__/validate.test.ts b/src/cli/commands/add/__tests__/validate.test.ts index 08ba8de5..1e14ef6a 100644 --- a/src/cli/commands/add/__tests__/validate.test.ts +++ b/src/cli/commands/add/__tests__/validate.test.ts @@ -237,7 +237,7 @@ describe('validate', () => { describe('validateAddGatewayTargetOptions', () => { // AC15: Required fields validated - it('returns error for missing required fields', () => { + it('returns error for missing required fields', async () => { const requiredFields: { field: keyof AddGatewayTargetOptions; error: string }[] = [ { field: 'name', error: '--name is required' }, { field: 'language', error: '--language is required' }, @@ -246,44 +246,49 @@ describe('validate', () => { for (const { field, error } of requiredFields) { const opts = { ...validGatewayTargetOptionsMcpRuntime, [field]: undefined }; - const result = validateAddGatewayTargetOptions(opts); + const result = await validateAddGatewayTargetOptions(opts); expect(result.valid, `Should fail for missing ${String(field)}`).toBe(false); expect(result.error).toBe(error); } }); // AC16: Invalid values rejected - it('returns error for invalid values', () => { - let result = validateAddGatewayTargetOptions({ ...validGatewayTargetOptionsMcpRuntime, language: 'Java' as any }); + it('returns error for invalid values', async () => { + let result = await validateAddGatewayTargetOptions({ + ...validGatewayTargetOptionsMcpRuntime, + language: 'Java' as any, + }); expect(result.valid).toBe(false); expect(result.error?.includes('Invalid language')).toBeTruthy(); - result = validateAddGatewayTargetOptions({ ...validGatewayTargetOptionsMcpRuntime, exposure: 'invalid' as any }); + result = await validateAddGatewayTargetOptions({ + ...validGatewayTargetOptionsMcpRuntime, + exposure: 'invalid' as any, + }); expect(result.valid).toBe(false); expect(result.error?.includes('Invalid exposure')).toBeTruthy(); }); // AC17: mcp-runtime exposure requires agents - it('returns error for mcp-runtime without agents', () => { - let result = validateAddGatewayTargetOptions({ ...validGatewayTargetOptionsMcpRuntime, agents: undefined }); + it('returns error for mcp-runtime without agents', async () => { + let result = await validateAddGatewayTargetOptions({ ...validGatewayTargetOptionsMcpRuntime, agents: undefined }); expect(result.valid).toBe(false); expect(result.error).toBe('--agents is required for mcp-runtime exposure'); - result = validateAddGatewayTargetOptions({ ...validGatewayTargetOptionsMcpRuntime, agents: ',,,' }); + result = await validateAddGatewayTargetOptions({ ...validGatewayTargetOptionsMcpRuntime, agents: ',,,' }); expect(result.valid).toBe(false); expect(result.error).toBe('At least one agent is required'); }); - // AC18: behind-gateway exposure is disabled (coming soon) - it('returns coming soon error for behind-gateway exposure', () => { - const result = validateAddGatewayTargetOptions({ ...validGatewayTargetOptionsBehindGateway }); - expect(result.valid).toBe(false); - expect(result.error).toContain('coming soon'); + // AC18: behind-gateway exposure is enabled + it('passes for valid behind-gateway options', async () => { + const result = await validateAddGatewayTargetOptions({ ...validGatewayTargetOptionsBehindGateway }); + expect(result.valid).toBe(true); }); // AC19: Valid options pass - it('passes for valid mcp-runtime options', () => { - expect(validateAddGatewayTargetOptions(validGatewayTargetOptionsMcpRuntime)).toEqual({ valid: true }); + it('passes for valid mcp-runtime options', async () => { + expect(await validateAddGatewayTargetOptions(validGatewayTargetOptionsMcpRuntime)).toEqual({ valid: true }); }); }); diff --git a/src/cli/commands/add/actions.ts b/src/cli/commands/add/actions.ts index c6df1ff1..cf83d3e6 100644 --- a/src/cli/commands/add/actions.ts +++ b/src/cli/commands/add/actions.ts @@ -71,6 +71,8 @@ export interface ValidatedAddGatewayTargetOptions { agents?: string; gateway?: string; host?: 'Lambda' | 'AgentCoreRuntime'; + outboundAuthType?: 'OAUTH' | 'API_KEY' | 'NONE'; + credentialName?: string; } export interface ValidatedAddMemoryOptions { @@ -286,6 +288,16 @@ function buildGatewayTargetConfig(options: ValidatedAddGatewayTargetOptions): Ad const sourcePath = `${APP_DIR}/${MCP_APP_SUBDIR}/${options.name}`; const description = options.description ?? `Tool for ${options.name}`; + + // Build outboundAuth configuration if provided + const outboundAuth = + options.outboundAuthType && options.outboundAuthType !== 'NONE' + ? { + type: options.outboundAuthType, + credentialName: options.credentialName, + } + : undefined; + return { name: options.name, description, @@ -306,6 +318,7 @@ function buildGatewayTargetConfig(options: ValidatedAddGatewayTargetOptions): Ad .filter(Boolean) : [], gateway: options.exposure === 'behind-gateway' ? options.gateway : undefined, + outboundAuth, }; } diff --git a/src/cli/commands/add/command.tsx b/src/cli/commands/add/command.tsx index 0b1e8abe..1e7a2170 100644 --- a/src/cli/commands/add/command.tsx +++ b/src/cli/commands/add/command.tsx @@ -64,8 +64,7 @@ async function handleAddAgentCLI(options: AddAgentOptions): Promise { process.exit(result.success ? 0 : 1); } -// Gateway disabled - rename to _handleAddGatewayCLI until feature is re-enabled -async function _handleAddGatewayCLI(options: AddGatewayOptions): Promise { +async function handleAddGatewayCLI(options: AddGatewayOptions): Promise { const validation = validateAddGatewayOptions(options); if (!validation.valid) { if (options.json) { @@ -97,9 +96,8 @@ async function _handleAddGatewayCLI(options: AddGatewayOptions): Promise { process.exit(result.success ? 0 : 1); } -// MCP Tool disabled - prefix with underscore until feature is re-enabled -async function _handleAddGatewayTargetCLI(options: AddGatewayTargetOptions): Promise { - const validation = validateAddGatewayTargetOptions(options); +async function handleAddGatewayTargetCLI(options: AddGatewayTargetOptions): Promise { + const validation = await validateAddGatewayTargetOptions(options); if (!validation.valid) { if (options.json) { console.log(JSON.stringify({ success: false, error: validation.error })); @@ -241,9 +239,9 @@ export function registerAdd(program: Command) { await handleAddAgentCLI(options as AddAgentOptions); }); - // Subcommand: add gateway (disabled - coming soon) + // Subcommand: add gateway addCmd - .command('gateway', { hidden: true }) + .command('gateway') .description('Add an MCP gateway to the project') .option('--name ', 'Gateway name') .option('--description ', 'Gateway description') @@ -253,14 +251,14 @@ export function registerAdd(program: Command) { .option('--allowed-clients ', 'Comma-separated allowed client IDs (required for CUSTOM_JWT)') .option('--agents ', 'Comma-separated agent names to attach gateway to') .option('--json', 'Output as JSON') - .action(() => { - console.error('AgentCore Gateway integration is coming soon.'); - process.exit(1); + .action(async options => { + requireProject(); + await handleAddGatewayCLI(options as AddGatewayOptions); }); - // Subcommand: add gateway-target (disabled - coming soon) + // Subcommand: add gateway-target addCmd - .command('gateway-target', { hidden: true }) + .command('gateway-target') .description('Add a gateway target to the project') .option('--name ', 'Tool name') .option('--description ', 'Tool description') @@ -270,9 +268,9 @@ export function registerAdd(program: Command) { .option('--gateway ', 'Gateway name (for behind-gateway)') .option('--host ', 'Compute host: Lambda or AgentCoreRuntime (for behind-gateway)') .option('--json', 'Output as JSON') - .action(() => { - console.error('MCP Tool integration is coming soon.'); - process.exit(1); + .action(async options => { + requireProject(); + await handleAddGatewayTargetCLI(options as AddGatewayTargetOptions); }); // Subcommand: add memory (v2: top-level resource) diff --git a/src/cli/commands/add/types.ts b/src/cli/commands/add/types.ts index ad002ff8..bdab405d 100644 --- a/src/cli/commands/add/types.ts +++ b/src/cli/commands/add/types.ts @@ -50,6 +50,8 @@ export interface AddGatewayTargetOptions { agents?: string; gateway?: string; host?: 'Lambda' | 'AgentCoreRuntime'; + outboundAuthType?: 'OAUTH' | 'API_KEY' | 'NONE'; + credentialName?: string; json?: boolean; } diff --git a/src/cli/commands/add/validate.ts b/src/cli/commands/add/validate.ts index 037a6df1..2934b25d 100644 --- a/src/cli/commands/add/validate.ts +++ b/src/cli/commands/add/validate.ts @@ -1,3 +1,4 @@ +import { ConfigIO } from '../../../lib'; import { AgentNameSchema, BuildTypeSchema, @@ -25,6 +26,35 @@ const MEMORY_OPTIONS = ['none', 'shortTerm', 'longAndShortTerm'] as const; const OIDC_WELL_KNOWN_SUFFIX = '/.well-known/openid-configuration'; const VALID_STRATEGIES = ['SEMANTIC', 'SUMMARIZATION', 'USER_PREFERENCE']; +/** + * Validate that a credential name exists in the project spec. + */ +async function validateCredentialExists(credentialName: string): Promise { + try { + const configIO = new ConfigIO(); + const project = await configIO.readProjectSpec(); + + const credentialExists = project.credentials.some(c => c.name === credentialName); + if (!credentialExists) { + const availableCredentials = project.credentials.map(c => c.name); + if (availableCredentials.length === 0) { + return { + valid: false, + error: `Credential "${credentialName}" not found. No credentials are configured. Add credentials using 'agentcore add identity'.`, + }; + } + return { + valid: false, + error: `Credential "${credentialName}" not found. Available credentials: ${availableCredentials.join(', ')}`, + }; + } + + return { valid: true }; + } catch { + return { valid: false, error: 'Failed to read project configuration' }; + } +} + // Agent validation export function validateAddAgentOptions(options: AddAgentOptions): ValidationResult { if (!options.name) { @@ -154,7 +184,7 @@ export function validateAddGatewayOptions(options: AddGatewayOptions): Validatio } // MCP Tool validation -export function validateAddGatewayTargetOptions(options: AddGatewayTargetOptions): ValidationResult { +export async function validateAddGatewayTargetOptions(options: AddGatewayTargetOptions): Promise { if (!options.name) { return { valid: false, error: '--name is required' }; } @@ -172,15 +202,7 @@ export function validateAddGatewayTargetOptions(options: AddGatewayTargetOptions } if (options.exposure !== 'mcp-runtime' && options.exposure !== 'behind-gateway') { - return { valid: false, error: 'Invalid exposure. Use mcp-runtime' }; - } - - // Gateway feature is disabled - if (options.exposure === 'behind-gateway') { - return { - valid: false, - error: "Behind-gateway exposure is coming soon. Use 'mcp-runtime' exposure instead.", - }; + return { valid: false, error: "Invalid exposure. Use 'mcp-runtime' or 'behind-gateway'" }; } if (options.exposure === 'mcp-runtime') { @@ -196,6 +218,22 @@ export function validateAddGatewayTargetOptions(options: AddGatewayTargetOptions } } + // Validate outbound auth configuration + if (options.outboundAuthType && options.outboundAuthType !== 'NONE') { + if (!options.credentialName) { + return { + valid: false, + error: `--credential-name is required when outbound auth type is ${options.outboundAuthType}`, + }; + } + + // Validate that the credential exists + const credentialValidation = await validateCredentialExists(options.credentialName); + if (!credentialValidation.valid) { + return credentialValidation; + } + } + return { valid: true }; } diff --git a/src/cli/commands/deploy/actions.ts b/src/cli/commands/deploy/actions.ts index 537f55b9..1b022c99 100644 --- a/src/cli/commands/deploy/actions.ts +++ b/src/cli/commands/deploy/actions.ts @@ -1,7 +1,7 @@ import { ConfigIO, SecureCredentials } from '../../../lib'; import { validateAwsCredentials } from '../../aws/account'; import { createSwitchableIoHost } from '../../cdk/toolkit-lib'; -import { buildDeployedState, getStackOutputs, parseAgentOutputs } from '../../cloudformation'; +import { buildDeployedState, getStackOutputs, parseAgentOutputs, parseGatewayOutputs } from '../../cloudformation'; import { getErrorMessage } from '../../errors'; import { ExecLogger } from '../../logging'; import { @@ -64,6 +64,15 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise r.status === 'error')?.error ?? 'Identity setup failed'; + const errorResult = identityResult.results.find(r => r.status === 'error'); + const errorMsg = + errorResult?.error && typeof errorResult.error === 'string' ? errorResult.error : 'Identity setup failed'; endStep('error', errorMsg); logger.finalize(false); return { success: false, error: errorMsg, logPath: logger.getRelativeLogPath() }; @@ -190,7 +201,9 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise 0; + const deployStepName = hasGateways ? 'Deploying gateways...' : 'Deploy to AWS'; + startStep(deployStepName); // Enable verbose output for resource-level events if (switchableIoHost && options.onResourceEvent) { @@ -215,11 +228,12 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise a.name); + const agentNames = context.projectSpec.agents?.map(a => a.name) || []; const agents = parseAgentOutputs(outputs, agentNames, stackName); + + // Parse gateway outputs + const gatewaySpecs = + mcpSpec?.agentCoreGateways?.reduce( + (acc, gateway) => { + acc[gateway.name] = gateway; + return acc; + }, + {} as Record + ) ?? {}; + const gateways = parseGatewayOutputs(outputs, gatewaySpecs); + const existingState = await configIO.readDeployedState().catch(() => undefined); - const deployedState = buildDeployedState(target.name, stackName, agents, existingState, identityKmsKeyArn); + const deployedState = buildDeployedState( + target.name, + stackName, + agents, + gateways, + existingState, + identityKmsKeyArn + ); await configIO.writeDeployedState(deployedState); + + // Show gateway URLs if any were deployed + if (Object.keys(gateways).length > 0) { + const gatewayUrls = Object.entries(gateways) + .map(([name, gateway]) => `${name}: ${gateway.gatewayArn}`) + .join(', '); + logger.log(`Gateway URLs: ${gatewayUrls}`); + } + endStep('success'); logger.finalize(true); @@ -255,7 +297,7 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise { registerResourceRemove(removeCommand, 'memory', 'memory', 'Remove a memory provider from the project'); registerResourceRemove(removeCommand, 'identity', 'identity', 'Remove an identity provider from the project'); - // MCP Tool disabled - replace with registerResourceRemove() call when enabling - removeCommand - .command('gateway-target', { hidden: true }) - .description('Remove an MCP tool from the project') - .option('--name ', 'Name of resource to remove') - .option('--force', 'Skip confirmation prompt') - .option('--json', 'Output as JSON') - .action(() => { - console.error('MCP Tool integration is coming soon.'); - process.exit(1); - }); + registerResourceRemove(removeCommand, 'gateway-target', 'gateway-target', 'Remove an MCP tool from the project'); - // Gateway disabled - replace with registerResourceRemove() call when enabling - removeCommand - .command('gateway', { hidden: true }) - .description('Remove a gateway from the project') - .option('--name ', 'Name of resource to remove') - .option('--force', 'Skip confirmation prompt') - .option('--json', 'Output as JSON') - .action(() => { - console.error('AgentCore Gateway integration is coming soon.'); - process.exit(1); - }); + registerResourceRemove(removeCommand, 'gateway', 'gateway', 'Remove a gateway from the project'); // IMPORTANT: Register the catch-all argument LAST. No subcommands should be registered after this point. removeCommand diff --git a/src/cli/operations/deploy/preflight.ts b/src/cli/operations/deploy/preflight.ts index 8401f68f..61f7c048 100644 --- a/src/cli/operations/deploy/preflight.ts +++ b/src/cli/operations/deploy/preflight.ts @@ -73,14 +73,25 @@ export async function validateProject(): Promise { const projectSpec = await configIO.readProjectSpec(); const awsTargets = await configIO.readAWSDeploymentTargets(); - // Validate that at least one agent is defined, unless this is a teardown deploy. + // Validate that at least one agent or gateway is defined, unless this is a teardown deploy. // // Teardown detection: when agents is empty but deployed-state.json records existing // targets, the user has run `remove all` and wants to tear down AWS resources via deploy. // deployed-state.json is written by the CLI after every successful deploy, so it is a // reliable indicator of whether a CloudFormation stack exists for this project. let isTeardownDeploy = false; - if (!projectSpec.agents || projectSpec.agents.length === 0) { + const hasAgents = projectSpec.agents && projectSpec.agents.length > 0; + + // Check for gateways in mcp.json + let hasGateways = false; + try { + const mcpSpec = await configIO.readMcpSpec(); + hasGateways = mcpSpec.agentCoreGateways && mcpSpec.agentCoreGateways.length > 0; + } catch { + // No mcp.json or invalid — no gateways + } + + if (!hasAgents && !hasGateways) { let hasExistingStack = false; try { const deployedState = await configIO.readDeployedState(); @@ -90,7 +101,7 @@ export async function validateProject(): Promise { } if (!hasExistingStack) { throw new Error( - 'No agents defined in project. Add at least one agent with "agentcore add agent" before deploying.' + 'No agents or gateways defined in project. Add at least one agent with "agentcore add agent" or gateway with "agentcore add gateway" before deploying.' ); } isTeardownDeploy = true; @@ -116,7 +127,7 @@ export async function validateProject(): Promise { */ function validateRuntimeNames(projectSpec: AgentCoreProjectSpec): void { const projectName = projectSpec.name; - for (const agent of projectSpec.agents) { + for (const agent of projectSpec.agents || []) { const agentName = agent.name; if (agentName) { const combinedName = `${projectName}_${agentName}`; @@ -136,7 +147,7 @@ function validateRuntimeNames(projectSpec: AgentCoreProjectSpec): void { */ export function validateContainerAgents(projectSpec: AgentCoreProjectSpec, configRoot: string): void { const errors: string[] = []; - for (const agent of projectSpec.agents) { + for (const agent of projectSpec.agents || []) { if (agent.build === 'Container') { const codeLocation = resolveCodeLocation(agent.codeLocation, configRoot); const dockerfilePath = path.join(codeLocation, DOCKERFILE_NAME); diff --git a/src/cli/operations/mcp/create-mcp.ts b/src/cli/operations/mcp/create-mcp.ts index f246fc69..c4ef2607 100644 --- a/src/cli/operations/mcp/create-mcp.ts +++ b/src/cli/operations/mcp/create-mcp.ts @@ -177,12 +177,38 @@ function validateGatewayTargetLanguage(language: string): asserts language is 'P } } +/** + * Validate that a credential name exists in the project spec. + */ +async function validateCredentialName(credentialName: string): Promise { + const configIO = new ConfigIO(); + const project = await configIO.readProjectSpec(); + + const credentialExists = project.credentials.some(c => c.name === credentialName); + if (!credentialExists) { + const availableCredentials = project.credentials.map(c => c.name); + if (availableCredentials.length === 0) { + throw new Error( + `Credential "${credentialName}" not found. No credentials are configured. Add credentials using 'agentcore add identity'.` + ); + } + throw new Error( + `Credential "${credentialName}" not found. Available credentials: ${availableCredentials.join(', ')}` + ); + } +} + /** * Create an MCP tool (MCP runtime or behind gateway). */ export async function createToolFromWizard(config: AddGatewayTargetConfig): Promise { validateGatewayTargetLanguage(config.language); + // Validate credential if outboundAuth is configured + if (config.outboundAuth?.credentialName) { + await validateCredentialName(config.outboundAuth.credentialName); + } + const configIO = new ConfigIO(); const mcpSpec: AgentCoreMcpSpec = configIO.configExists('mcp') ? await configIO.readMcpSpec() @@ -301,6 +327,7 @@ export async function createToolFromWizard(config: AddGatewayTargetConfig): Prom networkMode: 'PUBLIC', }, }, + ...(config.outboundAuth && { outboundAuth: config.outboundAuth }), }; gateway.targets.push(target); diff --git a/src/cli/operations/remove/remove-identity.ts b/src/cli/operations/remove/remove-identity.ts index 14ed3b4f..68c9e417 100644 --- a/src/cli/operations/remove/remove-identity.ts +++ b/src/cli/operations/remove/remove-identity.ts @@ -42,6 +42,30 @@ export async function previewRemoveCredential(credentialName: string): Promise 0) { + summary.push( + `Warning: Credential "${credentialName}" is referenced by gateway targets: ${referencingTargets.join(', ')}. Removing it may break these targets.` + ); + } + const schemaChanges: SchemaChange[] = []; const afterSpec = { @@ -71,6 +95,29 @@ export async function removeCredential(credentialName: string): Promise 0) { + console.warn( + `Warning: Credential "${credentialName}" is referenced by gateway targets: ${referencingTargets.join(', ')}. Removing it may break these targets.` + ); + } + project.credentials.splice(credentialIndex, 1); await configIO.writeProjectSpec(project); diff --git a/src/cli/tui/components/ResourceGraph.tsx b/src/cli/tui/components/ResourceGraph.tsx index a9772bae..4fe99a85 100644 --- a/src/cli/tui/components/ResourceGraph.tsx +++ b/src/cli/tui/components/ResourceGraph.tsx @@ -1,5 +1,6 @@ import type { AgentCoreDeployedState, + AgentCoreGatewayTarget, AgentCoreMcpRuntimeTool, AgentCoreMcpSpec, AgentCoreProjectSpec, @@ -23,7 +24,7 @@ export interface AgentStatusInfo { interface ResourceGraphProps { project: AgentCoreProjectSpec; - mcp?: AgentCoreMcpSpec; + mcp?: AgentCoreMcpSpec & { unassignedTargets?: AgentCoreGatewayTarget[] }; agentName?: string; agentStatuses?: Record; deployedAgents?: Record; @@ -92,13 +93,15 @@ export function ResourceGraph({ const credentials = project.credentials ?? []; const gateways = mcp?.agentCoreGateways ?? []; const mcpRuntimeTools = mcp?.mcpRuntimeTools ?? []; + const unassignedTargets = mcp?.unassignedTargets ?? []; const hasContent = agents.length > 0 || memories.length > 0 || credentials.length > 0 || gateways.length > 0 || - mcpRuntimeTools.length > 0; + mcpRuntimeTools.length > 0 || + unassignedTargets.length > 0; return ( @@ -173,12 +176,19 @@ export function ResourceGraph({ name={gateway.name} detail={tools.length > 0 ? `${tools.length} tools` : undefined} /> - {tools.map(tool => ( - - {' '} - {ICONS.tool} {tool.name} - - ))} + {targets.map(target => { + const displayText = + target.targetType === 'mcpServer' && target.endpoint ? target.endpoint : target.name; + return ( + + {' '} + {ICONS.tool} {displayText} + {target.targetType === 'mcpServer' && target.endpoint && ( + [{target.targetType}] + )} + + ); + })} ); })} @@ -200,6 +210,20 @@ export function ResourceGraph({ )} + {/* Unassigned Targets */} + {unassignedTargets.length > 0 && ( + + ⚠ Unassigned Targets + {unassignedTargets.map((target, idx) => { + const displayText = + target.targetType === 'mcpServer' && target.endpoint + ? target.endpoint + : (target.name ?? `Target ${idx + 1}`); + return ; + })} + + )} + {/* Empty state */} {!hasContent && {'\n'} No resources configured} diff --git a/src/cli/tui/components/__tests__/ResourceGraph.test.tsx b/src/cli/tui/components/__tests__/ResourceGraph.test.tsx index 60c07003..bbdf6883 100644 --- a/src/cli/tui/components/__tests__/ResourceGraph.test.tsx +++ b/src/cli/tui/components/__tests__/ResourceGraph.test.tsx @@ -105,7 +105,7 @@ describe('ResourceGraph', () => { agentCoreGateways: [ { name: 'my-gateway', - targets: [{ toolDefinitions: [{ name: 'tool-a' }, { name: 'tool-b' }] }], + targets: [{ name: 'target-a', toolDefinitions: [{ name: 'tool-a' }, { name: 'tool-b' }] }], }, ], } as unknown as AgentCoreMcpSpec; @@ -115,7 +115,7 @@ describe('ResourceGraph', () => { expect(lastFrame()).toContain('Gateways'); expect(lastFrame()).toContain('my-gateway'); expect(lastFrame()).toContain('2 tools'); - expect(lastFrame()).toContain('tool-a'); + expect(lastFrame()).toContain('target-a'); }); it('renders MCP runtime tools', () => { diff --git a/src/cli/tui/screens/add/AddScreen.tsx b/src/cli/tui/screens/add/AddScreen.tsx index f0d90de4..2a35ff1e 100644 --- a/src/cli/tui/screens/add/AddScreen.tsx +++ b/src/cli/tui/screens/add/AddScreen.tsx @@ -6,8 +6,8 @@ const ADD_RESOURCES = [ { id: 'agent', title: 'Agent', description: 'New or existing agent code' }, { id: 'memory', title: 'Memory', description: 'Persistent context storage' }, { id: 'identity', title: 'Identity', description: 'API key credential providers' }, - { id: 'gateway', title: 'Gateway (coming soon)', description: 'Route and manage MCP tools', disabled: true }, - { id: 'gateway-target', title: 'MCP Tool (coming soon)', description: 'Extend agent capabilities', disabled: true }, + { id: 'gateway', title: 'Gateway', description: 'Route and manage MCP tools' }, + { id: 'gateway-target', title: 'MCP Tool', description: 'Extend agent capabilities' }, ] as const; export type AddResourceType = (typeof ADD_RESOURCES)[number]['id']; @@ -24,7 +24,7 @@ export function AddScreen({ onSelect, onExit, hasAgents }: AddScreenProps) { () => ADD_RESOURCES.map(r => ({ ...r, - disabled: ('disabled' in r && r.disabled) || ((r.id === 'memory' || r.id === 'identity') && !hasAgents), + disabled: Boolean('disabled' in r && r.disabled) || ((r.id === 'memory' || r.id === 'identity') && !hasAgents), description: (r.id === 'memory' || r.id === 'identity') && !hasAgents ? 'Add an agent first' : r.description, })), [hasAgents] diff --git a/src/cli/tui/screens/deploy/useDeployFlow.ts b/src/cli/tui/screens/deploy/useDeployFlow.ts index c88dd57d..eaf9b16b 100644 --- a/src/cli/tui/screens/deploy/useDeployFlow.ts +++ b/src/cli/tui/screens/deploy/useDeployFlow.ts @@ -129,7 +129,7 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState if (!ctx || !currentStackName || !target) return; const configIO = new ConfigIO(); - const agentNames = ctx.projectSpec.agents.map((a: { name: string }) => a.name); + const agentNames = ctx.projectSpec.agents?.map((a: { name: string }) => a.name) || []; // Try to get outputs from CDK stream first (immediate, no API call) let outputs = streamOutputsRef.current ?? {}; @@ -167,7 +167,14 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState setStackOutputs(outputs); const existingState = await configIO.readDeployedState().catch(() => undefined); - const deployedState = buildDeployedState(target.name, currentStackName, agents, existingState, identityKmsKeyArn); + const deployedState = buildDeployedState( + target.name, + currentStackName, + agents, + {}, + existingState, + identityKmsKeyArn + ); await configIO.writeDeployedState(deployedState); }, [context, stackNames, logger, identityKmsKeyArn]); diff --git a/src/cli/tui/screens/mcp/AddGatewayScreen.tsx b/src/cli/tui/screens/mcp/AddGatewayScreen.tsx index 4577e4c8..0a6bba97 100644 --- a/src/cli/tui/screens/mcp/AddGatewayScreen.tsx +++ b/src/cli/tui/screens/mcp/AddGatewayScreen.tsx @@ -139,12 +139,19 @@ export function AddGatewayScreen({ onComplete, onExit, existingGateways, availab )} {isAuthorizerStep && ( - + + + {authorizerItems[authorizerNav.selectedIndex]?.id === 'NONE' && ( + + ⚠️ Warning: Gateway will be publicly accessible without authorization + + )} + )} {isJwtConfigStep && ( diff --git a/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx b/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx index 6278bb9c..f3a5c45f 100644 --- a/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx +++ b/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx @@ -46,7 +46,7 @@ export function AddGatewayTargetScreen({ id: o.id, title: o.title, description: o.description, - disabled: 'disabled' in o ? o.disabled : undefined, + disabled: 'disabled' in o ? Boolean(o.disabled) : undefined, })), [] ); diff --git a/src/cli/tui/screens/mcp/types.ts b/src/cli/tui/screens/mcp/types.ts index 89b74bc5..3c781d32 100644 --- a/src/cli/tui/screens/mcp/types.ts +++ b/src/cli/tui/screens/mcp/types.ts @@ -65,6 +65,12 @@ export interface AddGatewayTargetConfig { toolDefinition: ToolDefinition; /** Agent names to attach (only when exposure = mcp-runtime) */ selectedAgents: string[]; + /** Outbound auth configuration */ + outboundAuth?: { + type: 'OAUTH' | 'API_KEY' | 'NONE'; + credentialName?: string; + scopes?: string[]; + }; } export const MCP_TOOL_STEP_LABELS: Record = { @@ -82,8 +88,9 @@ export const MCP_TOOL_STEP_LABELS: Record = { // ───────────────────────────────────────────────────────────────────────────── export const AUTHORIZER_TYPE_OPTIONS = [ - { id: 'NONE', title: 'None', description: 'No authorization required' }, + { id: 'AWS_IAM', title: 'AWS IAM', description: 'AWS Identity and Access Management authorization' }, { id: 'CUSTOM_JWT', title: 'Custom JWT', description: 'JWT-based authorization via OIDC provider' }, + { id: 'NONE', title: 'None', description: 'No authorization required — gateway is publicly accessible' }, ] as const; export const TARGET_LANGUAGE_OPTIONS = [ @@ -96,9 +103,8 @@ export const EXPOSURE_MODE_OPTIONS = [ { id: 'mcp-runtime', title: 'MCP Runtime', description: 'Deploy as AgentCore MCP Runtime (select agents to attach)' }, { id: 'behind-gateway', - title: 'Behind Gateway (coming soon)', + title: 'Behind Gateway', description: 'Route through AgentCore Gateway', - disabled: true, }, ] as const; diff --git a/src/cli/tui/screens/remove/RemoveScreen.tsx b/src/cli/tui/screens/remove/RemoveScreen.tsx index fe140276..b739ba66 100644 --- a/src/cli/tui/screens/remove/RemoveScreen.tsx +++ b/src/cli/tui/screens/remove/RemoveScreen.tsx @@ -6,8 +6,8 @@ const REMOVE_RESOURCES = [ { id: 'agent', title: 'Agent', description: 'Remove an agent from the project' }, { id: 'memory', title: 'Memory', description: 'Remove a memory provider' }, { id: 'identity', title: 'Identity', description: 'Remove an identity provider' }, - { id: 'gateway', title: 'Gateway (coming soon)', description: 'Remove an MCP gateway', disabled: true }, - { id: 'gateway-target', title: 'MCP Tool (coming soon)', description: 'Remove an MCP tool', disabled: true }, + { id: 'gateway', title: 'Gateway', description: 'Remove an MCP gateway' }, + { id: 'gateway-target', title: 'MCP Tool', description: 'Remove an MCP tool' }, { id: 'all', title: 'All', description: 'Reset entire agentcore project' }, ] as const; @@ -32,16 +32,14 @@ export function RemoveScreen({ onSelect, onExit, agentCount, - // Gateway disabled - prefix with underscore until feature is re-enabled - gatewayCount: _gatewayCount, - // MCP Tool disabled - prefix with underscore until feature is re-enabled - mcpToolCount: _mcpToolCount, + gatewayCount, + mcpToolCount, memoryCount, identityCount, }: RemoveScreenProps) { const items: SelectableItem[] = useMemo(() => { return REMOVE_RESOURCES.map(r => { - let disabled = ('disabled' in r && r.disabled) || false; + let disabled = Boolean('disabled' in r && r.disabled); let description: string = r.description; switch (r.id) { @@ -51,6 +49,18 @@ export function RemoveScreen({ description = 'No agents to remove'; } break; + case 'gateway': + if (gatewayCount === 0) { + disabled = true; + description = 'No gateways to remove'; + } + break; + case 'gateway-target': + if (mcpToolCount === 0) { + disabled = true; + description = 'No gateway targets to remove'; + } + break; case 'memory': if (memoryCount === 0) { disabled = true; @@ -70,7 +80,7 @@ export function RemoveScreen({ return { ...r, disabled, description }; }); - }, [agentCount, memoryCount, identityCount]); + }, [agentCount, gatewayCount, mcpToolCount, memoryCount, identityCount]); const isDisabled = (item: SelectableItem) => item.disabled ?? false; diff --git a/src/cli/tui/screens/schema/McpGuidedEditor.tsx b/src/cli/tui/screens/schema/McpGuidedEditor.tsx index f8c0b74b..95977e9a 100644 --- a/src/cli/tui/screens/schema/McpGuidedEditor.tsx +++ b/src/cli/tui/screens/schema/McpGuidedEditor.tsx @@ -1,9 +1,11 @@ import { type AgentCoreGateway, + type AgentCoreGatewayTarget, type AgentCoreMcpRuntimeTool, type AgentCoreMcpSpec, AgentCoreMcpSpecSchema, GatewayNameSchema, + type OutboundAuth, } from '../../../../schema'; import { Header, Panel, ScreenLayout, TextInput } from '../../components'; import { useSchemaDocument } from '../../hooks/useSchemaDocument'; @@ -48,7 +50,10 @@ export function McpGuidedEditor(props: McpGuidedEditorProps) { ); } - let mcpSpec: AgentCoreMcpSpec = { agentCoreGateways: [] }; + let mcpSpec: AgentCoreMcpSpec & { unassignedTargets?: AgentCoreGatewayTarget[] } = { + agentCoreGateways: [], + unassignedTargets: [], + }; try { const parsed: unknown = JSON.parse(content); const result = AgentCoreMcpSpecSchema.safeParse(parsed); @@ -79,7 +84,7 @@ type ScreenMode = 'list' | 'confirm-exit' | 'edit-item' | 'edit-field' | 'edit-t function McpEditorBody(props: { schema: SchemaOption; - initialSpec: AgentCoreMcpSpec; + initialSpec: AgentCoreMcpSpec & { unassignedTargets?: AgentCoreGatewayTarget[] }; baseline: string; onBack: () => void; onSave: (content: string) => Promise<{ ok: boolean; error?: string }>; @@ -89,6 +94,9 @@ function McpEditorBody(props: { const [mcpRuntimeTools, setMcpRuntimeTools] = useState( props.initialSpec.mcpRuntimeTools ?? [] ); + const [unassignedTargets, _setUnassignedTargets] = useState( + props.initialSpec.unassignedTargets ?? [] + ); const [viewMode, setViewMode] = useState('gateways'); const [selectedIndex, setSelectedIndex] = useState(0); const [expandedIndex, setExpandedIndex] = useState(null); @@ -116,12 +124,19 @@ function McpEditorBody(props: { const currentFields = viewMode === 'gateways' ? gatewayFields : mcpRuntimeFields; // Target fields - const targetFields = [{ id: 'targetName', label: 'Target Name' }]; + const currentTarget = currentGateway?.targets?.[selectedTargetIndex]; + const targetFields = [ + { id: 'targetName', label: 'Target Name' }, + { id: 'targetType', label: 'Target Type' }, + ...(currentTarget?.targetType === 'mcpServer' ? [{ id: 'endpoint', label: 'Endpoint URL' }] : []), + { id: 'outboundAuth', label: 'Outbound Auth' }, + ]; async function commitChanges() { - const spec: AgentCoreMcpSpec = { + const spec: AgentCoreMcpSpec & { unassignedTargets?: AgentCoreGatewayTarget[] } = { agentCoreGateways: gateways, ...(mcpRuntimeTools.length > 0 ? { mcpRuntimeTools: mcpRuntimeTools } : {}), + ...(unassignedTargets.length > 0 ? { unassignedTargets: unassignedTargets } : {}), }; const content = JSON.stringify(spec, null, 2); const result = await props.onSave(content); @@ -417,7 +432,9 @@ function McpEditorBody(props: { const selected = idx === selectedTargetIndex; const targetName = target.name ?? `Target ${idx + 1}`; const toolCount = target.toolDefinitions?.length ?? 0; - const host = target.compute?.host ?? target.targetType; + const targetType = target.targetType; + const endpoint = target.endpoint; + const displayInfo = endpoint ?? target.compute?.host ?? targetType; return ( {selected ? '❯' : ' '} @@ -425,7 +442,7 @@ function McpEditorBody(props: { {targetName} - ({toolCount} tools · {host}) + ({toolCount} tools · {targetType} · {displayInfo}) ); @@ -451,17 +468,42 @@ function McpEditorBody(props: { let initialValue = ''; if (editingTargetFieldId === 'targetName') { initialValue = target.name ?? ''; + } else if (editingTargetFieldId === 'targetType') { + initialValue = target.targetType ?? ''; + } else if (editingTargetFieldId === 'endpoint') { + initialValue = target.endpoint ?? ''; + } else if (editingTargetFieldId === 'outboundAuth') { + const auth = target.outboundAuth; + initialValue = auth ? `${auth.type}${auth.credentialName ? `:${auth.credentialName}` : ''}` : 'NONE'; } const handleSubmit = (value: string) => { if (viewMode === 'gateways' && gateway) { const updatedTargets = [...(gateway.targets ?? [])]; const targetToUpdate = updatedTargets[selectedTargetIndex]; - if (targetToUpdate && editingTargetFieldId === 'targetName') { - updatedTargets[selectedTargetIndex] = { - ...targetToUpdate, - name: value, - }; + if (targetToUpdate) { + if (editingTargetFieldId === 'targetName') { + updatedTargets[selectedTargetIndex] = { ...targetToUpdate, name: value }; + } else if (editingTargetFieldId === 'targetType') { + const validTypes = ['mcpServer', 'lambda', 'openApiSchema', 'smithyModel'] as const; + const targetType = validTypes.includes(value as (typeof validTypes)[number]) + ? (value as (typeof validTypes)[number]) + : targetToUpdate.targetType; + updatedTargets[selectedTargetIndex] = { ...targetToUpdate, targetType }; + } else if (editingTargetFieldId === 'endpoint') { + updatedTargets[selectedTargetIndex] = { ...targetToUpdate, endpoint: value || undefined }; + } else if (editingTargetFieldId === 'outboundAuth') { + const [type, credentialName] = value.split(':'); + const validAuthTypes = ['NONE', 'OAUTH', 'API_KEY'] as const; + const authType = validAuthTypes.includes(type as (typeof validAuthTypes)[number]) + ? (type as (typeof validAuthTypes)[number]) + : 'NONE'; + const outboundAuth: OutboundAuth = { + type: authType, + ...(credentialName ? { credentialName } : {}), + }; + updatedTargets[selectedTargetIndex] = { ...targetToUpdate, outboundAuth }; + } const next = gateways.map((g, idx) => (idx === selectedIndex ? { ...g, targets: updatedTargets } : g)); setGateways(next); setDirty(true); @@ -478,7 +520,17 @@ function McpEditorBody(props: { { setEditingTargetFieldId(null); @@ -492,9 +544,10 @@ function McpEditorBody(props: { // Confirm exit screen if (screenMode === 'confirm-exit') { - const spec: AgentCoreMcpSpec = { + const spec: AgentCoreMcpSpec & { unassignedTargets?: AgentCoreGatewayTarget[] } = { agentCoreGateways: gateways, ...(mcpRuntimeTools.length > 0 ? { mcpRuntimeTools: mcpRuntimeTools } : {}), + ...(unassignedTargets.length > 0 ? { unassignedTargets: unassignedTargets } : {}), }; const currentText = JSON.stringify(spec, null, 2); const diffOps = diffLines(props.baseline.split('\n'), currentText.split('\n')); @@ -646,6 +699,31 @@ function McpEditorBody(props: { )} + {/* Unassigned Targets */} + {unassignedTargets.length > 0 && ( + + + + {unassignedTargets.map((target, idx) => { + const targetName = target.name ?? `Target ${idx + 1}`; + const targetType = target.targetType; + const endpoint = target.endpoint; + const displayInfo = endpoint ?? target.compute?.host ?? targetType; + return ( + + + {targetName} + + ({targetType} · {displayInfo}) + + + ); + })} + + + + )} + {dirty && ( ● Changes pending From da7da9d96ac21a8304cf213069f553a0211ef345 Mon Sep 17 00:00:00 2001 From: Aidan Daly <99039782+aidandaly24@users.noreply.github.com> Date: Mon, 23 Feb 2026 16:05:53 -0500 Subject: [PATCH 04/28] feat: add external MCP server target support and unassigned targets (#406) --- src/cli/commands/add/actions.ts | 5 ++ src/cli/commands/add/command.tsx | 3 + src/cli/commands/add/types.ts | 3 + src/cli/commands/add/validate.ts | 30 +++++++ src/cli/operations/mcp/create-mcp.ts | 58 ++++++++++++- .../tui/screens/mcp/AddGatewayTargetFlow.tsx | 28 +++++-- .../screens/mcp/AddGatewayTargetScreen.tsx | 83 ++++++++++++++++--- src/cli/tui/screens/mcp/types.ts | 24 +++++- .../screens/mcp/useAddGatewayTargetWizard.ts | 64 ++++++++++---- src/schema/schemas/mcp.ts | 1 + 10 files changed, 261 insertions(+), 38 deletions(-) diff --git a/src/cli/commands/add/actions.ts b/src/cli/commands/add/actions.ts index cf83d3e6..f52723cc 100644 --- a/src/cli/commands/add/actions.ts +++ b/src/cli/commands/add/actions.ts @@ -66,6 +66,9 @@ export interface ValidatedAddGatewayOptions { export interface ValidatedAddGatewayTargetOptions { name: string; description?: string; + type?: string; + source?: 'existing-endpoint' | 'create-new'; + endpoint?: string; language: 'Python' | 'TypeScript' | 'Other'; exposure: 'mcp-runtime' | 'behind-gateway'; agents?: string; @@ -304,6 +307,8 @@ function buildGatewayTargetConfig(options: ValidatedAddGatewayTargetOptions): Ad sourcePath, language: options.language, exposure: options.exposure, + source: options.source, + endpoint: options.endpoint, host: options.exposure === 'mcp-runtime' ? 'AgentCoreRuntime' : options.host!, toolDefinition: { name: options.name, diff --git a/src/cli/commands/add/command.tsx b/src/cli/commands/add/command.tsx index 1e7a2170..89e8125f 100644 --- a/src/cli/commands/add/command.tsx +++ b/src/cli/commands/add/command.tsx @@ -262,6 +262,9 @@ export function registerAdd(program: Command) { .description('Add a gateway target to the project') .option('--name ', 'Tool name') .option('--description ', 'Tool description') + .option('--type ', 'Target type: mcpServer or lambda') + .option('--source ', 'Source: existing-endpoint or create-new') + .option('--endpoint ', 'MCP server endpoint URL') .option('--language ', 'Language: Python or TypeScript') .option('--exposure ', 'Exposure mode: mcp-runtime or behind-gateway') .option('--agents ', 'Comma-separated agent names (for mcp-runtime)') diff --git a/src/cli/commands/add/types.ts b/src/cli/commands/add/types.ts index bdab405d..984351c0 100644 --- a/src/cli/commands/add/types.ts +++ b/src/cli/commands/add/types.ts @@ -45,6 +45,9 @@ export interface AddGatewayResult { export interface AddGatewayTargetOptions { name?: string; description?: string; + type?: string; + source?: string; + endpoint?: string; language?: 'Python' | 'TypeScript' | 'Other'; exposure?: 'mcp-runtime' | 'behind-gateway'; agents?: string; diff --git a/src/cli/commands/add/validate.ts b/src/cli/commands/add/validate.ts index 2934b25d..2331b369 100644 --- a/src/cli/commands/add/validate.ts +++ b/src/cli/commands/add/validate.ts @@ -189,6 +189,36 @@ export async function validateAddGatewayTargetOptions(options: AddGatewayTargetO return { valid: false, error: '--name is required' }; } + if (options.type && options.type !== 'mcpServer' && options.type !== 'lambda') { + return { valid: false, error: 'Invalid type. Valid options: mcpServer, lambda' }; + } + + if (options.source && options.source !== 'existing-endpoint' && options.source !== 'create-new') { + return { valid: false, error: 'Invalid source. Valid options: existing-endpoint, create-new' }; + } + + if (options.source === 'existing-endpoint') { + if (!options.endpoint) { + return { valid: false, error: '--endpoint is required when source is existing-endpoint' }; + } + + try { + const url = new URL(options.endpoint); + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return { valid: false, error: 'Endpoint must use http:// or https:// protocol' }; + } + } catch { + return { valid: false, error: 'Endpoint must be a valid URL (e.g. https://example.com/mcp)' }; + } + + // Populate defaults for fields skipped by external endpoint flow + options.language ??= 'Other'; + options.exposure ??= 'behind-gateway'; + options.gateway ??= undefined; + + return { valid: true }; + } + if (!options.language) { return { valid: false, error: '--language is required' }; } diff --git a/src/cli/operations/mcp/create-mcp.ts b/src/cli/operations/mcp/create-mcp.ts index c4ef2607..fe517515 100644 --- a/src/cli/operations/mcp/create-mcp.ts +++ b/src/cli/operations/mcp/create-mcp.ts @@ -12,7 +12,12 @@ import type { import { AgentCoreCliMcpDefsSchema, ToolDefinitionSchema } from '../../../schema'; import { getTemplateToolDefinitions, renderGatewayTargetTemplate } from '../../templates/GatewayTargetRenderer'; import type { AddGatewayConfig, AddGatewayTargetConfig } from '../../tui/screens/mcp/types'; -import { DEFAULT_HANDLER, DEFAULT_NODE_VERSION, DEFAULT_PYTHON_VERSION } from '../../tui/screens/mcp/types'; +import { + DEFAULT_HANDLER, + DEFAULT_NODE_VERSION, + DEFAULT_PYTHON_VERSION, + SKIP_FOR_NOW, +} from '../../tui/screens/mcp/types'; import { existsSync } from 'fs'; import { mkdir, readFile, writeFile } from 'fs/promises'; import { dirname, join } from 'path'; @@ -198,6 +203,57 @@ async function validateCredentialName(credentialName: string): Promise { } } +/** + * Create an external MCP server target (existing endpoint). + */ +export async function createExternalGatewayTarget(config: AddGatewayTargetConfig): Promise { + if (!config.endpoint) { + throw new Error('Endpoint URL is required for external MCP server targets.'); + } + + const configIO = new ConfigIO(); + const mcpSpec: AgentCoreMcpSpec = configIO.configExists('mcp') + ? await configIO.readMcpSpec() + : { agentCoreGateways: [], unassignedTargets: [] }; + + const target: AgentCoreGatewayTarget = { + name: config.name, + targetType: 'mcpServer', + endpoint: config.endpoint, + toolDefinitions: [config.toolDefinition], + ...(config.outboundAuth && { outboundAuth: config.outboundAuth }), + }; + + if (config.gateway && config.gateway !== SKIP_FOR_NOW) { + // Assign to specific gateway + const gateway = mcpSpec.agentCoreGateways.find(g => g.name === config.gateway); + if (!gateway) { + throw new Error(`Gateway "${config.gateway}" not found.`); + } + + // Check for duplicate target name + if (gateway.targets.some(t => t.name === config.name)) { + throw new Error(`Target "${config.name}" already exists in gateway "${gateway.name}".`); + } + + gateway.targets.push(target); + } else { + // Add to unassigned targets + mcpSpec.unassignedTargets ??= []; + + // Check for duplicate target name in unassigned targets + if (mcpSpec.unassignedTargets.some((t: AgentCoreGatewayTarget) => t.name === config.name)) { + throw new Error(`Unassigned target "${config.name}" already exists.`); + } + + mcpSpec.unassignedTargets.push(target); + } + + await configIO.writeMcpSpec(mcpSpec); + + return { mcpDefsPath: '', toolName: config.name, projectPath: '' }; +} + /** * Create an MCP tool (MCP runtime or behind gateway). */ diff --git a/src/cli/tui/screens/mcp/AddGatewayTargetFlow.tsx b/src/cli/tui/screens/mcp/AddGatewayTargetFlow.tsx index 28ec0a91..d31fde83 100644 --- a/src/cli/tui/screens/mcp/AddGatewayTargetFlow.tsx +++ b/src/cli/tui/screens/mcp/AddGatewayTargetFlow.tsx @@ -1,3 +1,4 @@ +import { createExternalGatewayTarget } from '../../../operations/mcp/create-mcp'; import { ErrorPrompt, Panel, Screen, TextInput, WizardSelect } from '../../components'; import type { SelectableItem } from '../../components'; import { HELP_TEXT } from '../../constants'; @@ -114,14 +115,25 @@ export function AddGatewayTargetFlow({ loading: true, loadingMessage: 'Creating MCP tool...', }); - void createTool(config).then(result => { - if (result.ok) { - const { toolName, projectPath } = result.result; - setFlow({ name: 'create-success', toolName, projectPath }); - return; - } - setFlow({ name: 'error', message: result.error }); - }); + + if (config.source === 'existing-endpoint') { + void createExternalGatewayTarget(config) + .then((result: { toolName: string; projectPath: string }) => { + setFlow({ name: 'create-success', toolName: result.toolName, projectPath: result.projectPath }); + }) + .catch((err: unknown) => { + setFlow({ name: 'error', message: err instanceof Error ? err.message : 'Unknown error' }); + }); + } else { + void createTool(config).then(result => { + if (result.ok) { + const { toolName, projectPath } = result.result; + setFlow({ name: 'create-success', toolName, projectPath }); + return; + } + setFlow({ name: 'error', message: result.error }); + }); + } }, [createTool] ); diff --git a/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx b/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx index f3a5c45f..6a447420 100644 --- a/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx +++ b/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx @@ -13,7 +13,14 @@ import { HELP_TEXT } from '../../constants'; import { useListNavigation, useMultiSelectNavigation } from '../../hooks'; import { generateUniqueName } from '../../utils'; import type { AddGatewayTargetConfig, ComputeHost, ExposureMode, TargetLanguage } from './types'; -import { COMPUTE_HOST_OPTIONS, EXPOSURE_MODE_OPTIONS, MCP_TOOL_STEP_LABELS, TARGET_LANGUAGE_OPTIONS } from './types'; +import { + COMPUTE_HOST_OPTIONS, + EXPOSURE_MODE_OPTIONS, + MCP_TOOL_STEP_LABELS, + SKIP_FOR_NOW, + SOURCE_OPTIONS, + TARGET_LANGUAGE_OPTIONS, +} from './types'; import { useAddGatewayTargetWizard } from './useAddGatewayTargetWizard'; import { Box, Text } from 'ink'; import React, { useMemo } from 'react'; @@ -35,6 +42,11 @@ export function AddGatewayTargetScreen({ }: AddGatewayTargetScreenProps) { const wizard = useAddGatewayTargetWizard(existingGateways, existingAgents); + const sourceItems: SelectableItem[] = useMemo( + () => SOURCE_OPTIONS.map(o => ({ id: o.id, title: o.title, description: o.description })), + [] + ); + const languageItems: SelectableItem[] = useMemo( () => TARGET_LANGUAGE_OPTIONS.map(o => ({ id: o.id, title: o.title, description: o.description })), [] @@ -52,7 +64,10 @@ export function AddGatewayTargetScreen({ ); const gatewayItems: SelectableItem[] = useMemo( - () => existingGateways.map(g => ({ id: g, title: g })), + () => [ + ...existingGateways.map(g => ({ id: g, title: g })), + { id: SKIP_FOR_NOW, title: 'Skip for now', description: 'Create unassigned target' }, + ], [existingGateways] ); @@ -63,16 +78,24 @@ export function AddGatewayTargetScreen({ const agentItems: SelectableItem[] = useMemo(() => existingAgents.map(a => ({ id: a, title: a })), [existingAgents]); + const isSourceStep = wizard.step === 'source'; const isLanguageStep = wizard.step === 'language'; const isExposureStep = wizard.step === 'exposure'; const isAgentsStep = wizard.step === 'agents'; const isGatewayStep = wizard.step === 'gateway'; const isHostStep = wizard.step === 'host'; - const isTextStep = wizard.step === 'name'; + const isTextStep = wizard.step === 'name' || wizard.step === 'endpoint'; const isConfirmStep = wizard.step === 'confirm'; const noGatewaysAvailable = isGatewayStep && existingGateways.length === 0; const noAgentsAvailable = isAgentsStep && existingAgents.length === 0; + const sourceNav = useListNavigation({ + items: sourceItems, + onSelect: item => wizard.setSource(item.id as 'existing-endpoint' | 'create-new'), + onExit: () => wizard.goBack(), + isActive: isSourceStep, + }); + const languageNav = useListNavigation({ items: languageItems, onSelect: item => wizard.setLanguage(item.id as TargetLanguage), @@ -132,6 +155,15 @@ export function AddGatewayTargetScreen({ return ( + {isSourceStep && ( + + )} + {isLanguageStep && ( )} @@ -180,12 +212,29 @@ export function AddGatewayTargetScreen({ {isTextStep && ( (wizard.currentIndex === 0 ? onExit() : wizard.goBack())} - schema={ToolNameSchema} - customValidation={value => !existingToolNames.includes(value) || 'Tool name already exists'} + schema={wizard.step === 'name' ? ToolNameSchema : undefined} + customValidation={ + wizard.step === 'name' + ? value => !existingToolNames.includes(value) || 'Tool name already exists' + : wizard.step === 'endpoint' + ? value => { + try { + const url = new URL(value); + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return 'Endpoint must use http:// or https:// protocol'; + } + return true; + } catch { + return 'Must be a valid URL (e.g. https://example.com/mcp)'; + } + } + : undefined + } /> )} @@ -193,14 +242,24 @@ export function AddGatewayTargetScreen({ 0 ? [{ label: 'Agents', value: wizard.config.selectedAgents.join(', ') }] : []), ...(!isMcpRuntime && wizard.config.gateway ? [{ label: 'Gateway', value: wizard.config.gateway }] : []), - { label: 'Host', value: wizard.config.host }, - { label: 'Source', value: wizard.config.sourcePath }, + ...(!isMcpRuntime && !wizard.config.gateway + ? [{ label: 'Gateway', value: '(none - assign later)' }] + : []), + ...(wizard.config.source === 'create-new' ? [{ label: 'Host', value: wizard.config.host }] : []), + ...(wizard.config.source === 'create-new' ? [{ label: 'Source', value: wizard.config.sourcePath }] : []), ]} /> )} diff --git a/src/cli/tui/screens/mcp/types.ts b/src/cli/tui/screens/mcp/types.ts index 3c781d32..6307e77b 100644 --- a/src/cli/tui/screens/mcp/types.ts +++ b/src/cli/tui/screens/mcp/types.ts @@ -47,7 +47,16 @@ export type ComputeHost = 'Lambda' | 'AgentCoreRuntime'; * - host: Select compute host (only if behind-gateway) * - confirm: Review and confirm */ -export type AddGatewayTargetStep = 'name' | 'language' | 'exposure' | 'agents' | 'gateway' | 'host' | 'confirm'; +export type AddGatewayTargetStep = + | 'name' + | 'source' + | 'endpoint' + | 'language' + | 'exposure' + | 'agents' + | 'gateway' + | 'host' + | 'confirm'; export type TargetLanguage = 'Python' | 'TypeScript' | 'Other'; @@ -57,6 +66,10 @@ export interface AddGatewayTargetConfig { sourcePath: string; language: TargetLanguage; exposure: ExposureMode; + /** Source type for external endpoints */ + source?: 'existing-endpoint' | 'create-new'; + /** External endpoint URL */ + endpoint?: string; /** Gateway name (only when exposure = behind-gateway) */ gateway?: string; /** Compute host (AgentCoreRuntime for mcp-runtime, Lambda or AgentCoreRuntime for behind-gateway) */ @@ -75,6 +88,8 @@ export interface AddGatewayTargetConfig { export const MCP_TOOL_STEP_LABELS: Record = { name: 'Name', + source: 'Source', + endpoint: 'Endpoint', language: 'Language', exposure: 'Exposure', agents: 'Agents', @@ -93,6 +108,13 @@ export const AUTHORIZER_TYPE_OPTIONS = [ { id: 'NONE', title: 'None', description: 'No authorization required — gateway is publicly accessible' }, ] as const; +export const SKIP_FOR_NOW = 'skip-for-now' as const; + +export const SOURCE_OPTIONS = [ + { id: 'existing-endpoint', title: 'Existing endpoint', description: 'Connect to an existing MCP server' }, + { id: 'create-new', title: 'Create new', description: 'Scaffold a new MCP server' }, +] as const; + export const TARGET_LANGUAGE_OPTIONS = [ { id: 'Python', title: 'Python', description: 'FastMCP Python server' }, { id: 'TypeScript', title: 'TypeScript', description: 'MCP TypeScript server' }, diff --git a/src/cli/tui/screens/mcp/useAddGatewayTargetWizard.ts b/src/cli/tui/screens/mcp/useAddGatewayTargetWizard.ts index 2deda24e..e5934fd0 100644 --- a/src/cli/tui/screens/mcp/useAddGatewayTargetWizard.ts +++ b/src/cli/tui/screens/mcp/useAddGatewayTargetWizard.ts @@ -1,18 +1,23 @@ import { APP_DIR, MCP_APP_SUBDIR } from '../../../../lib'; import type { ToolDefinition } from '../../../../schema'; import type { AddGatewayTargetConfig, AddGatewayTargetStep, ComputeHost, ExposureMode, TargetLanguage } from './types'; +import { SKIP_FOR_NOW } from './types'; import { useCallback, useMemo, useState } from 'react'; /** - * Dynamic steps based on exposure mode. - * - MCP Runtime: name → language → exposure → agents → confirm - * - Behind gateway: name → language → exposure → gateway → host → confirm + * Dynamic steps based on exposure mode and source. + * - Existing endpoint: name → source → endpoint → gateway → confirm + * - Create new MCP Runtime: name → source → language → exposure → agents → confirm + * - Create new Behind gateway: name → source → language → exposure → gateway → host → confirm */ -function getSteps(exposure: ExposureMode): AddGatewayTargetStep[] { +function getSteps(exposure: ExposureMode, source?: 'existing-endpoint' | 'create-new'): AddGatewayTargetStep[] { + if (source === 'existing-endpoint') { + return ['name', 'source', 'endpoint', 'gateway', 'confirm']; + } if (exposure === 'mcp-runtime') { - return ['name', 'language', 'exposure', 'agents', 'confirm']; + return ['name', 'source', 'language', 'exposure', 'agents', 'confirm']; } - return ['name', 'language', 'exposure', 'gateway', 'host', 'confirm']; + return ['name', 'source', 'language', 'exposure', 'gateway', 'host', 'confirm']; } function deriveToolDefinition(name: string): ToolDefinition { @@ -40,16 +45,16 @@ export function useAddGatewayTargetWizard(existingGateways: string[] = [], exist const [config, setConfig] = useState(getDefaultConfig); const [step, setStep] = useState('name'); - const steps = useMemo(() => getSteps(config.exposure), [config.exposure]); + const steps = useMemo(() => getSteps(config.exposure, config.source), [config.exposure, config.source]); const currentIndex = steps.indexOf(step); const goBack = useCallback(() => { - // Recalculate steps in case exposure changed - const currentSteps = getSteps(config.exposure); + // Recalculate steps in case exposure or source changed + const currentSteps = getSteps(config.exposure, config.source); const idx = currentSteps.indexOf(step); const prevStep = currentSteps[idx - 1]; if (prevStep) setStep(prevStep); - }, [config.exposure, step]); + }, [config.exposure, config.source, step]); const setName = useCallback((name: string) => { setConfig(c => ({ @@ -59,7 +64,27 @@ export function useAddGatewayTargetWizard(existingGateways: string[] = [], exist sourcePath: `${APP_DIR}/${MCP_APP_SUBDIR}/${name}`, toolDefinition: deriveToolDefinition(name), })); - setStep('language'); + setStep('source'); + }, []); + + const setSource = useCallback((source: 'existing-endpoint' | 'create-new') => { + setConfig(c => ({ + ...c, + source, + })); + if (source === 'existing-endpoint') { + setStep('endpoint'); + } else { + setStep('language'); + } + }, []); + + const setEndpoint = useCallback((endpoint: string) => { + setConfig(c => ({ + ...c, + endpoint, + })); + setStep('gateway'); }, []); const setLanguage = useCallback((language: TargetLanguage) => { @@ -101,11 +126,16 @@ export function useAddGatewayTargetWizard(existingGateways: string[] = [], exist }, []); const setGateway = useCallback((gateway: string) => { - setConfig(c => ({ - ...c, - gateway, - })); - setStep('host'); + setConfig(c => { + const isExternal = c.source === 'existing-endpoint'; + const isSkipped = gateway === SKIP_FOR_NOW; + if (isExternal || isSkipped) { + setStep('confirm'); + } else { + setStep('host'); + } + return { ...c, gateway: isSkipped ? undefined : gateway }; + }); }, []); const setHost = useCallback((host: ComputeHost) => { @@ -130,6 +160,8 @@ export function useAddGatewayTargetWizard(existingGateways: string[] = [], exist existingAgents, goBack, setName, + setSource, + setEndpoint, setLanguage, setExposure, setAgents, diff --git a/src/schema/schemas/mcp.ts b/src/schema/schemas/mcp.ts index f529da4a..c5047bf1 100644 --- a/src/schema/schemas/mcp.ts +++ b/src/schema/schemas/mcp.ts @@ -403,6 +403,7 @@ export const AgentCoreMcpSpecSchema = z .object({ agentCoreGateways: z.array(AgentCoreGatewaySchema), mcpRuntimeTools: z.array(AgentCoreMcpRuntimeToolSchema).optional(), + unassignedTargets: z.array(AgentCoreGatewayTargetSchema).optional(), }) .strict(); From 268facf896bd82ac480d62d50575bb291ca5b4ec Mon Sep 17 00:00:00 2001 From: Aidan Daly <99039782+aidandaly24@users.noreply.github.com> Date: Mon, 23 Feb 2026 17:12:04 -0500 Subject: [PATCH 05/28] feat: add OAuth credential provider creation during deploy (#407) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add OAuth credential provider creation during deploy * fix: address review comments — add clarifying comments for vendor config, race condition, and ARN inconsistency * fix: use typed SDK responses instead of Record casts * fix: on conflict, update OAuth provider instead of GET to avoid silently ignoring new credentials --- src/cli/cloudformation/outputs.ts | 8 +- src/cli/commands/deploy/actions.ts | 65 +++++-- src/cli/operations/deploy/index.ts | 5 + .../operations/deploy/pre-deploy-identity.ts | 140 ++++++++++++++- src/cli/operations/identity/index.ts | 8 + .../identity/oauth2-credential-provider.ts | 160 ++++++++++++++++++ src/schema/schemas/deployed-state.ts | 13 ++ 7 files changed, 384 insertions(+), 15 deletions(-) create mode 100644 src/cli/operations/identity/oauth2-credential-provider.ts diff --git a/src/cli/cloudformation/outputs.ts b/src/cli/cloudformation/outputs.ts index d10aa154..7e053e09 100644 --- a/src/cli/cloudformation/outputs.ts +++ b/src/cli/cloudformation/outputs.ts @@ -175,7 +175,8 @@ export function buildDeployedState( agents: Record, gateways: Record, existingState?: DeployedState, - identityKmsKeyArn?: string + identityKmsKeyArn?: string, + credentials?: Record ): DeployedState { const targetState: TargetDeployedState = { resources: { @@ -192,6 +193,11 @@ export function buildDeployedState( }; } + // Add credential state if credentials exist + if (credentials && Object.keys(credentials).length > 0) { + targetState.resources!.credentials = credentials; + } + return { targets: { ...existingState?.targets, diff --git a/src/cli/commands/deploy/actions.ts b/src/cli/commands/deploy/actions.ts index 1b022c99..b9cfc538 100644 --- a/src/cli/commands/deploy/actions.ts +++ b/src/cli/commands/deploy/actions.ts @@ -11,8 +11,10 @@ import { checkStackDeployability, getAllCredentials, hasOwnedIdentityApiProviders, + hasOwnedIdentityOAuthProviders, performStackTeardown, setupApiKeyProviders, + setupOAuth2Providers, synthesizeCdk, validateProject, } from '../../operations/deploy'; @@ -166,20 +168,21 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise = {}; - for (const cred of neededCredentials) { - const value = process.env[cred.envVarName]; - if (value) { - envCredentials[cred.envVarName] = value; - } + // Read runtime credentials from process.env (enables non-interactive deploy with -y) + const neededCredentials = getAllCredentials(context.projectSpec); + const envCredentials: Record = {}; + for (const cred of neededCredentials) { + const value = process.env[cred.envVarName]; + if (value) { + envCredentials[cred.envVarName] = value; } - const runtimeCredentials = - Object.keys(envCredentials).length > 0 ? new SecureCredentials(envCredentials) : undefined; + } + const runtimeCredentials = + Object.keys(envCredentials).length > 0 ? new SecureCredentials(envCredentials) : undefined; + + if (hasOwnedIdentityApiProviders(context.projectSpec)) { + startStep('Creating credentials...'); const identityResult = await setupApiKeyProviders({ projectSpec: context.projectSpec, @@ -200,6 +203,41 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise = {}; + if (hasOwnedIdentityOAuthProviders(context.projectSpec)) { + startStep('Creating OAuth credentials...'); + + const oauthResult = await setupOAuth2Providers({ + projectSpec: context.projectSpec, + configBaseDir: configIO.getConfigRoot(), + region: target.region, + runtimeCredentials, + }); + if (oauthResult.hasErrors) { + const errorResult = oauthResult.results.find(r => r.status === 'error'); + const errorMsg = errorResult?.error ?? 'OAuth credential setup failed'; + endStep('error', errorMsg); + logger.finalize(false); + return { success: false, error: errorMsg, logPath: logger.getRelativeLogPath() }; + } + + // Collect credential ARNs for deployed state + for (const result of oauthResult.results) { + if (result.credentialProviderArn) { + oauthCredentials[result.providerName] = { + credentialProviderArn: result.credentialProviderArn, + clientSecretArn: result.clientSecretArn, + callbackUrl: result.callbackUrl, + }; + } + } + endStep('success'); + } + // Deploy const hasGateways = mcpSpec?.agentCoreGateways && mcpSpec.agentCoreGateways.length > 0; const deployStepName = hasGateways ? 'Deploying gateways...' : 'Deploy to AWS'; @@ -273,7 +311,8 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise { + const { projectSpec, configBaseDir, region, runtimeCredentials } = options; + const results: OAuth2ProviderSetupResult[] = []; + const credentials = getCredentialProvider(); + + const envVars = await readEnvFile(configBaseDir); + const envCredentials = SecureCredentials.fromEnvVars(envVars); + const allCredentials = runtimeCredentials ? envCredentials.merge(runtimeCredentials) : envCredentials; + + const client = new BedrockAgentCoreControlClient({ region, credentials }); + + for (const credential of projectSpec.credentials) { + if (credential.type === 'OAuthCredentialProvider') { + const result = await setupSingleOAuth2Provider(client, credential, allCredentials); + results.push(result); + } + } + + return { + results, + hasErrors: results.some(r => r.status === 'error'), + }; +} + +/** + * Check if the project has any OAuth credentials that need setup. + */ +export function hasOwnedIdentityOAuthProviders(projectSpec: AgentCoreProjectSpec): boolean { + return projectSpec.credentials.some(c => c.type === 'OAuthCredentialProvider'); +} + +async function setupSingleOAuth2Provider( + client: BedrockAgentCoreControlClient, + credential: Credential, + credentials: SecureCredentials +): Promise { + if (credential.type !== 'OAuthCredentialProvider') { + return { providerName: credential.name, status: 'error', error: 'Invalid credential type' }; + } + + const nameKey = credential.name.toUpperCase().replace(/-/g, '_'); + const clientIdEnvVar = `AGENTCORE_CREDENTIAL_${nameKey}_CLIENT_ID`; + const clientSecretEnvVar = `AGENTCORE_CREDENTIAL_${nameKey}_CLIENT_SECRET`; + + const clientId = credentials.get(clientIdEnvVar); + const clientSecret = credentials.get(clientSecretEnvVar); + + if (!clientId || !clientSecret) { + return { + providerName: credential.name, + status: 'skipped', + error: `Missing ${clientIdEnvVar} or ${clientSecretEnvVar} in agentcore/.env.local`, + }; + } + + const params = { + name: credential.name, + vendor: credential.vendor, + discoveryUrl: credential.discoveryUrl, + clientId, + clientSecret, + }; + + try { + const exists = await oAuth2ProviderExists(client, credential.name); + + if (exists) { + const updateResult = await updateOAuth2Provider(client, params); + return { + providerName: credential.name, + status: updateResult.success ? 'updated' : 'error', + error: updateResult.error, + credentialProviderArn: updateResult.result?.credentialProviderArn, + clientSecretArn: updateResult.result?.clientSecretArn, + callbackUrl: updateResult.result?.callbackUrl, + }; + } + + const createResult = await createOAuth2Provider(client, params); + return { + providerName: credential.name, + status: createResult.success ? 'created' : 'error', + error: createResult.error, + credentialProviderArn: createResult.result?.credentialProviderArn, + clientSecretArn: createResult.result?.clientSecretArn, + callbackUrl: createResult.result?.callbackUrl, + }; + } catch (error) { + let errorMessage: string; + if (isNoCredentialsError(error)) { + errorMessage = 'AWS credentials not found. Run `aws sso login` or set AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY.'; + } else { + errorMessage = error instanceof Error ? error.message : String(error); + } + return { providerName: credential.name, status: 'error', error: errorMessage }; + } +} diff --git a/src/cli/operations/identity/index.ts b/src/cli/operations/identity/index.ts index 5c9cd4ff..05c33e74 100644 --- a/src/cli/operations/identity/index.ts +++ b/src/cli/operations/identity/index.ts @@ -4,6 +4,14 @@ export { setTokenVaultKmsKey, updateApiKeyProvider, } from './api-key-credential-provider'; +export { + createOAuth2Provider, + getOAuth2Provider, + oAuth2ProviderExists, + updateOAuth2Provider, + type OAuth2ProviderParams, + type OAuth2ProviderResult, +} from './oauth2-credential-provider'; export { computeDefaultCredentialEnvVarName, resolveCredentialStrategy, diff --git a/src/cli/operations/identity/oauth2-credential-provider.ts b/src/cli/operations/identity/oauth2-credential-provider.ts new file mode 100644 index 00000000..e148d61f --- /dev/null +++ b/src/cli/operations/identity/oauth2-credential-provider.ts @@ -0,0 +1,160 @@ +/** + * Imperative AWS SDK operations for OAuth2 credential providers. + * + * This file exists because AgentCore Identity resources are not yet modeled + * as CDK constructs. These operations run as a pre-deploy step outside the + * main CDK synthesis/deploy path. + */ +import { + BedrockAgentCoreControlClient, + CreateOauth2CredentialProviderCommand, + type CredentialProviderVendorType, + GetOauth2CredentialProviderCommand, + ResourceNotFoundException, + UpdateOauth2CredentialProviderCommand, +} from '@aws-sdk/client-bedrock-agentcore-control'; + +export interface OAuth2ProviderResult { + credentialProviderArn: string; + clientSecretArn?: string; + callbackUrl?: string; +} + +export interface OAuth2ProviderParams { + name: string; + vendor: string; + discoveryUrl: string; + clientId: string; + clientSecret: string; +} + +/** + * Extract result fields from an OAuth2 API response. + * All Create/Get/Update responses share the same shape. + */ +function extractResult(response: { + credentialProviderArn?: string; + clientSecretArn?: { secretArn?: string }; + callbackUrl?: string; +}): OAuth2ProviderResult | undefined { + if (!response.credentialProviderArn) return undefined; + return { + credentialProviderArn: response.credentialProviderArn, + clientSecretArn: response.clientSecretArn?.secretArn, + callbackUrl: response.callbackUrl, + }; +} + +/** + * Check if an OAuth2 credential provider exists. + */ +export async function oAuth2ProviderExists( + client: BedrockAgentCoreControlClient, + providerName: string +): Promise { + try { + await client.send(new GetOauth2CredentialProviderCommand({ name: providerName })); + return true; + } catch (error) { + if (error instanceof ResourceNotFoundException) { + return false; + } + throw error; + } +} + +/** + * Build the OAuth2 provider config for Create/Update commands. + * Always uses customOauth2ProviderConfig — the vendor field controls server-side + * behavior (token endpoints, scopes), but the config shape is the same for all + * vendors in the current API. Vendor-specific config paths (e.g. googleOauth2ProviderConfig) + * would be needed if we add vendor selection in a future phase. + */ +function buildOAuth2Config(params: OAuth2ProviderParams) { + return { + name: params.name, + credentialProviderVendor: params.vendor as CredentialProviderVendorType, + oauth2ProviderConfigInput: { + customOauth2ProviderConfig: { + clientId: params.clientId, + clientSecret: params.clientSecret, + oauthDiscovery: { + discoveryUrl: params.discoveryUrl, + }, + }, + }, + }; +} + +/** + * Create an OAuth2 credential provider. + * On conflict (already exists), falls back to GET to retrieve the ARN. + */ +export async function createOAuth2Provider( + client: BedrockAgentCoreControlClient, + params: OAuth2ProviderParams +): Promise<{ success: boolean; result?: OAuth2ProviderResult; error?: string }> { + try { + const response = await client.send(new CreateOauth2CredentialProviderCommand(buildOAuth2Config(params))); + const result = extractResult(response); + if (!result) { + return { success: false, error: 'No credential provider ARN in response' }; + } + return { success: true, result }; + } catch (error) { + const errorName = (error as { name?: string }).name; + if (errorName === 'ConflictException' || errorName === 'ResourceAlreadyExistsException') { + // Race condition: another process created the provider between our exists-check and + // create call. Fall back to update so the user's credentials are always applied. + return updateOAuth2Provider(client, params); + } + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +/** + * Get an existing OAuth2 credential provider. + */ +export async function getOAuth2Provider( + client: BedrockAgentCoreControlClient, + name: string +): Promise<{ success: boolean; result?: OAuth2ProviderResult; error?: string }> { + try { + const response = await client.send(new GetOauth2CredentialProviderCommand({ name })); + const result = extractResult(response); + if (!result) { + return { success: false, error: 'No credential provider ARN in response' }; + } + return { success: true, result }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +/** + * Update an existing OAuth2 credential provider. + */ +export async function updateOAuth2Provider( + client: BedrockAgentCoreControlClient, + params: OAuth2ProviderParams +): Promise<{ success: boolean; result?: OAuth2ProviderResult; error?: string }> { + try { + const response = await client.send(new UpdateOauth2CredentialProviderCommand(buildOAuth2Config(params))); + const result = extractResult(response); + if (!result) { + return { success: false, error: 'No credential provider ARN in response' }; + } + return { success: true, result }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} diff --git a/src/schema/schemas/deployed-state.ts b/src/schema/schemas/deployed-state.ts index 9438bae5..be6efacd 100644 --- a/src/schema/schemas/deployed-state.ts +++ b/src/schema/schemas/deployed-state.ts @@ -95,6 +95,18 @@ export const ExternallyManagedStateSchema = z.object({ export type ExternallyManagedState = z.infer; +// ============================================================================ +// Credential Deployed State +// ============================================================================ + +export const CredentialDeployedStateSchema = z.object({ + credentialProviderArn: z.string(), + clientSecretArn: z.string().optional(), + callbackUrl: z.string().optional(), +}); + +export type CredentialDeployedState = z.infer; + // ============================================================================ // Deployed Resource State // ============================================================================ @@ -103,6 +115,7 @@ export const DeployedResourceStateSchema = z.object({ agents: z.record(z.string(), AgentCoreDeployedStateSchema).optional(), mcp: McpDeployedStateSchema.optional(), externallyManaged: ExternallyManagedStateSchema.optional(), + credentials: z.record(z.string(), CredentialDeployedStateSchema).optional(), stackName: z.string().optional(), identityKmsKeyArn: z.string().optional(), }); From bac6e3ce3d50afe402b306f0d8f665925739c3eb Mon Sep 17 00:00:00 2001 From: Aidan Daly <99039782+aidandaly24@users.noreply.github.com> Date: Tue, 24 Feb 2026 09:36:03 -0500 Subject: [PATCH 06/28] refactor: remove exposure/mcp-runtime mode from gateway-target command (#414) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: remove exposure/mcp-runtime mode from gateway-target command All gateway targets are now behind-gateway only. The mcp-runtime exposure mode was disabled and never used by customers. - Remove ExposureMode type, EXPOSURE_MODE_OPTIONS, --exposure and --agents CLI flags - Simplify wizard flow: name → source → language → gateway → host → confirm - Remove mcp-runtime code paths from create-mcp, remove-gateway-target, McpGuidedEditor - Remove existingAgents prop chain (only used for mcp-runtime agent selection) - Clean up unused imports (ViewMode, AgentCoreMcpRuntimeTool, WizardMultiSelect) * test: update tests for exposure removal - Remove mcp-runtime validation tests and fixtures - Remove mcp-runtime integration tests (add and remove) - Simplify gateway target test fixtures to behind-gateway only * style: fix formatting --- .../add/__tests__/add-gateway-target.test.ts | 174 +------------ .../commands/add/__tests__/validate.test.ts | 46 +--- src/cli/commands/add/actions.ts | 14 +- src/cli/commands/add/command.tsx | 8 +- src/cli/commands/add/types.ts | 2 - src/cli/commands/add/validate.ts | 22 -- .../__tests__/remove-gateway-target.test.ts | 89 +------ src/cli/operations/mcp/create-mcp.ts | 165 +++++------- .../remove/remove-gateway-target.ts | 129 +++------- src/cli/tui/screens/add/AddFlow.tsx | 1 - src/cli/tui/screens/mcp/AddGatewayFlow.tsx | 2 +- .../tui/screens/mcp/AddGatewayTargetFlow.tsx | 5 - .../screens/mcp/AddGatewayTargetScreen.tsx | 103 +------- src/cli/tui/screens/mcp/index.ts | 1 - src/cli/tui/screens/mcp/types.ts | 37 +-- .../screens/mcp/useAddGatewayTargetWizard.ts | 63 +---- .../remove/RemoveGatewayTargetScreen.tsx | 2 +- .../tui/screens/schema/McpGuidedEditor.tsx | 238 ++++++------------ 18 files changed, 217 insertions(+), 884 deletions(-) diff --git a/src/cli/commands/add/__tests__/add-gateway-target.test.ts b/src/cli/commands/add/__tests__/add-gateway-target.test.ts index c6d9846b..4b0ee8ca 100644 --- a/src/cli/commands/add/__tests__/add-gateway-target.test.ts +++ b/src/cli/commands/add/__tests__/add-gateway-target.test.ts @@ -9,43 +9,19 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; describe.skip('add gateway-target command', () => { let testDir: string; let projectDir: string; - const agentName = 'TestAgent'; - const gatewayName = 'test-gateway'; // Used in skipped behind-gateway tests + const gatewayName = 'test-gateway'; beforeAll(async () => { testDir = join(tmpdir(), `agentcore-add-gateway-target-${randomUUID()}`); await mkdir(testDir, { recursive: true }); - // Create project with agent + // Create project const projectName = 'GatewayTargetProj'; - let result = await runCLI(['create', '--name', projectName, '--no-agent'], testDir); + const result = await runCLI(['create', '--name', projectName, '--no-agent'], testDir); if (result.exitCode !== 0) { throw new Error(`Failed to create project: ${result.stdout} ${result.stderr}`); } projectDir = join(testDir, projectName); - - // Add agent for mcp-runtime tests - result = await runCLI( - [ - 'add', - 'agent', - '--name', - agentName, - '--language', - 'Python', - '--framework', - 'Strands', - '--model-provider', - 'Bedrock', - '--memory', - 'none', - '--json', - ], - projectDir - ); - if (result.exitCode !== 0) { - throw new Error(`Failed to create agent: ${result.stdout} ${result.stderr}`); - } }); afterAll(async () => { @@ -61,32 +37,9 @@ describe.skip('add gateway-target command', () => { expect(json.error.includes('--name'), `Error: ${json.error}`).toBeTruthy(); }); - it('requires exposure flag', async () => { - const result = await runCLI( - ['add', 'gateway-target', '--name', 'test', '--language', 'Python', '--json'], - projectDir - ); - expect(result.exitCode).toBe(1); - const json = JSON.parse(result.stdout); - expect(json.success).toBe(false); - expect(json.error.includes('--exposure'), `Error: ${json.error}`).toBeTruthy(); - }); - it('validates language', async () => { const result = await runCLI( - [ - 'add', - 'gateway-target', - '--name', - 'test', - '--language', - 'InvalidLang', - '--exposure', - 'mcp-runtime', - '--agents', - agentName, - '--json', - ], + ['add', 'gateway-target', '--name', 'test', '--language', 'InvalidLang', '--json'], projectDir ); expect(result.exitCode).toBe(1); @@ -100,19 +53,7 @@ describe.skip('add gateway-target command', () => { it('accepts Other as valid language option', async () => { const result = await runCLI( - [ - 'add', - 'gateway-target', - '--name', - 'container-tool', - '--language', - 'Other', - '--exposure', - 'mcp-runtime', - '--agents', - agentName, - '--json', - ], + ['add', 'gateway-target', '--name', 'container-tool', '--language', 'Other', '--json'], projectDir ); @@ -127,79 +68,6 @@ describe.skip('add gateway-target command', () => { }); }); - describe('mcp-runtime', () => { - it('creates mcp-runtime tool', async () => { - const toolName = `rttool${Date.now()}`; - const result = await runCLI( - [ - 'add', - 'gateway-target', - '--name', - toolName, - '--language', - 'Python', - '--exposure', - 'mcp-runtime', - '--agents', - agentName, - '--json', - ], - projectDir - ); - - expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); - const json = JSON.parse(result.stdout); - expect(json.success).toBe(true); - expect(json.toolName).toBe(toolName); - - // Verify in mcp.json - const mcpSpec = JSON.parse(await readFile(join(projectDir, 'agentcore/mcp.json'), 'utf-8')); - const tool = mcpSpec.mcpRuntimeTools?.find((t: { name: string }) => t.name === toolName); - expect(tool, 'Tool should be in mcpRuntimeTools').toBeTruthy(); - - // Verify agent has remote tool reference - const projectSpec = JSON.parse(await readFile(join(projectDir, 'agentcore/agentcore.json'), 'utf-8')); - const agent = projectSpec.agents.find((a: { name: string }) => a.name === agentName); - const hasRef = agent?.remoteTools?.some((rt: { mcpRuntimeName?: string }) => rt.mcpRuntimeName === toolName); - expect(hasRef, 'Agent should have remoteTools reference').toBeTruthy(); - }); - - it('requires agents for mcp-runtime', async () => { - const result = await runCLI( - ['add', 'gateway-target', '--name', 'no-agents', '--language', 'Python', '--exposure', 'mcp-runtime', '--json'], - projectDir - ); - expect(result.exitCode).toBe(1); - const json = JSON.parse(result.stdout); - expect(json.success).toBe(false); - expect(json.error.includes('--agents'), `Error: ${json.error}`).toBeTruthy(); - }); - - it('returns clear error for Other language with mcp-runtime', async () => { - const result = await runCLI( - [ - 'add', - 'gateway-target', - '--name', - 'runtime-container', - '--language', - 'Other', - '--exposure', - 'mcp-runtime', - '--agents', - agentName, - '--json', - ], - projectDir - ); - - expect(result.exitCode).toBe(1); - const json = JSON.parse(result.stdout); - expect(json.success).toBe(false); - expect(json.error.length > 0, 'Should have error message').toBeTruthy(); - }); - }); - // Gateway disabled - skip behind-gateway tests until gateway feature is enabled describe.skip('behind-gateway', () => { it('creates behind-gateway tool', async () => { @@ -212,8 +80,6 @@ describe.skip('add gateway-target command', () => { toolName, '--language', 'Python', - '--exposure', - 'behind-gateway', '--gateway', gatewayName, '--host', @@ -237,19 +103,7 @@ describe.skip('add gateway-target command', () => { it('requires gateway for behind-gateway', async () => { const result = await runCLI( - [ - 'add', - 'gateway-target', - '--name', - 'no-gw', - '--language', - 'Python', - '--exposure', - 'behind-gateway', - '--host', - 'Lambda', - '--json', - ], + ['add', 'gateway-target', '--name', 'no-gw', '--language', 'Python', '--host', 'Lambda', '--json'], projectDir ); expect(result.exitCode).toBe(1); @@ -260,19 +114,7 @@ describe.skip('add gateway-target command', () => { it('requires host for behind-gateway', async () => { const result = await runCLI( - [ - 'add', - 'gateway-target', - '--name', - 'no-host', - '--language', - 'Python', - '--exposure', - 'behind-gateway', - '--gateway', - gatewayName, - '--json', - ], + ['add', 'gateway-target', '--name', 'no-host', '--language', 'Python', '--gateway', gatewayName, '--json'], projectDir ); expect(result.exitCode).toBe(1); @@ -290,8 +132,6 @@ describe.skip('add gateway-target command', () => { 'gateway-container', '--language', 'Other', - '--exposure', - 'behind-gateway', '--gateway', gatewayName, '--host', diff --git a/src/cli/commands/add/__tests__/validate.test.ts b/src/cli/commands/add/__tests__/validate.test.ts index 1e14ef6a..f53b7187 100644 --- a/src/cli/commands/add/__tests__/validate.test.ts +++ b/src/cli/commands/add/__tests__/validate.test.ts @@ -46,17 +46,9 @@ const validGatewayOptionsJwt: AddGatewayOptions = { allowedClients: 'client1,client2', }; -const validGatewayTargetOptionsMcpRuntime: AddGatewayTargetOptions = { +const validGatewayTargetOptions: AddGatewayTargetOptions = { name: 'test-tool', language: 'Python', - exposure: 'mcp-runtime', - agents: 'Agent1,Agent2', -}; - -const validGatewayTargetOptionsBehindGateway: AddGatewayTargetOptions = { - name: 'test-tool', - language: 'Python', - exposure: 'behind-gateway', gateway: 'my-gateway', host: 'Lambda', }; @@ -241,11 +233,10 @@ describe('validate', () => { const requiredFields: { field: keyof AddGatewayTargetOptions; error: string }[] = [ { field: 'name', error: '--name is required' }, { field: 'language', error: '--language is required' }, - { field: 'exposure', error: '--exposure is required' }, ]; for (const { field, error } of requiredFields) { - const opts = { ...validGatewayTargetOptionsMcpRuntime, [field]: undefined }; + const opts = { ...validGatewayTargetOptions, [field]: undefined }; const result = await validateAddGatewayTargetOptions(opts); expect(result.valid, `Should fail for missing ${String(field)}`).toBe(false); expect(result.error).toBe(error); @@ -254,42 +245,19 @@ describe('validate', () => { // AC16: Invalid values rejected it('returns error for invalid values', async () => { - let result = await validateAddGatewayTargetOptions({ - ...validGatewayTargetOptionsMcpRuntime, + const result = await validateAddGatewayTargetOptions({ + ...validGatewayTargetOptions, language: 'Java' as any, }); expect(result.valid).toBe(false); expect(result.error?.includes('Invalid language')).toBeTruthy(); - - result = await validateAddGatewayTargetOptions({ - ...validGatewayTargetOptionsMcpRuntime, - exposure: 'invalid' as any, - }); - expect(result.valid).toBe(false); - expect(result.error?.includes('Invalid exposure')).toBeTruthy(); }); - // AC17: mcp-runtime exposure requires agents - it('returns error for mcp-runtime without agents', async () => { - let result = await validateAddGatewayTargetOptions({ ...validGatewayTargetOptionsMcpRuntime, agents: undefined }); - expect(result.valid).toBe(false); - expect(result.error).toBe('--agents is required for mcp-runtime exposure'); - - result = await validateAddGatewayTargetOptions({ ...validGatewayTargetOptionsMcpRuntime, agents: ',,,' }); - expect(result.valid).toBe(false); - expect(result.error).toBe('At least one agent is required'); - }); - - // AC18: behind-gateway exposure is enabled - it('passes for valid behind-gateway options', async () => { - const result = await validateAddGatewayTargetOptions({ ...validGatewayTargetOptionsBehindGateway }); + // AC18: Valid options pass + it('passes for valid gateway target options', async () => { + const result = await validateAddGatewayTargetOptions({ ...validGatewayTargetOptions }); expect(result.valid).toBe(true); }); - - // AC19: Valid options pass - it('passes for valid mcp-runtime options', async () => { - expect(await validateAddGatewayTargetOptions(validGatewayTargetOptionsMcpRuntime)).toEqual({ valid: true }); - }); }); describe('validateAddMemoryOptions', () => { diff --git a/src/cli/commands/add/actions.ts b/src/cli/commands/add/actions.ts index f52723cc..88c5dcc9 100644 --- a/src/cli/commands/add/actions.ts +++ b/src/cli/commands/add/actions.ts @@ -70,8 +70,6 @@ export interface ValidatedAddGatewayTargetOptions { source?: 'existing-endpoint' | 'create-new'; endpoint?: string; language: 'Python' | 'TypeScript' | 'Other'; - exposure: 'mcp-runtime' | 'behind-gateway'; - agents?: string; gateway?: string; host?: 'Lambda' | 'AgentCoreRuntime'; outboundAuthType?: 'OAUTH' | 'API_KEY' | 'NONE'; @@ -306,23 +304,15 @@ function buildGatewayTargetConfig(options: ValidatedAddGatewayTargetOptions): Ad description, sourcePath, language: options.language, - exposure: options.exposure, source: options.source, endpoint: options.endpoint, - host: options.exposure === 'mcp-runtime' ? 'AgentCoreRuntime' : options.host!, + host: options.host!, toolDefinition: { name: options.name, description, inputSchema: { type: 'object' }, }, - selectedAgents: - options.exposure === 'mcp-runtime' - ? options - .agents!.split(',') - .map(s => s.trim()) - .filter(Boolean) - : [], - gateway: options.exposure === 'behind-gateway' ? options.gateway : undefined, + gateway: options.gateway, outboundAuth, }; } diff --git a/src/cli/commands/add/command.tsx b/src/cli/commands/add/command.tsx index 89e8125f..cc5ae49c 100644 --- a/src/cli/commands/add/command.tsx +++ b/src/cli/commands/add/command.tsx @@ -111,8 +111,6 @@ async function handleAddGatewayTargetCLI(options: AddGatewayTargetOptions): Prom name: options.name!, description: options.description, language: options.language! as 'Python' | 'TypeScript', - exposure: options.exposure!, - agents: options.agents, gateway: options.gateway, host: options.host, }); @@ -266,10 +264,8 @@ export function registerAdd(program: Command) { .option('--source ', 'Source: existing-endpoint or create-new') .option('--endpoint ', 'MCP server endpoint URL') .option('--language ', 'Language: Python or TypeScript') - .option('--exposure ', 'Exposure mode: mcp-runtime or behind-gateway') - .option('--agents ', 'Comma-separated agent names (for mcp-runtime)') - .option('--gateway ', 'Gateway name (for behind-gateway)') - .option('--host ', 'Compute host: Lambda or AgentCoreRuntime (for behind-gateway)') + .option('--gateway ', 'Gateway name') + .option('--host ', 'Compute host: Lambda or AgentCoreRuntime') .option('--json', 'Output as JSON') .action(async options => { requireProject(); diff --git a/src/cli/commands/add/types.ts b/src/cli/commands/add/types.ts index 984351c0..b37785ba 100644 --- a/src/cli/commands/add/types.ts +++ b/src/cli/commands/add/types.ts @@ -49,8 +49,6 @@ export interface AddGatewayTargetOptions { source?: string; endpoint?: string; language?: 'Python' | 'TypeScript' | 'Other'; - exposure?: 'mcp-runtime' | 'behind-gateway'; - agents?: string; gateway?: string; host?: 'Lambda' | 'AgentCoreRuntime'; outboundAuthType?: 'OAUTH' | 'API_KEY' | 'NONE'; diff --git a/src/cli/commands/add/validate.ts b/src/cli/commands/add/validate.ts index 2331b369..0c4c0492 100644 --- a/src/cli/commands/add/validate.ts +++ b/src/cli/commands/add/validate.ts @@ -213,7 +213,6 @@ export async function validateAddGatewayTargetOptions(options: AddGatewayTargetO // Populate defaults for fields skipped by external endpoint flow options.language ??= 'Other'; - options.exposure ??= 'behind-gateway'; options.gateway ??= undefined; return { valid: true }; @@ -227,27 +226,6 @@ export async function validateAddGatewayTargetOptions(options: AddGatewayTargetO return { valid: false, error: 'Invalid language. Valid options: Python, TypeScript, Other' }; } - if (!options.exposure) { - return { valid: false, error: '--exposure is required' }; - } - - if (options.exposure !== 'mcp-runtime' && options.exposure !== 'behind-gateway') { - return { valid: false, error: "Invalid exposure. Use 'mcp-runtime' or 'behind-gateway'" }; - } - - if (options.exposure === 'mcp-runtime') { - if (!options.agents) { - return { valid: false, error: '--agents is required for mcp-runtime exposure' }; - } - const agents = options.agents - .split(',') - .map(s => s.trim()) - .filter(Boolean); - if (agents.length === 0) { - return { valid: false, error: 'At least one agent is required' }; - } - } - // Validate outbound auth configuration if (options.outboundAuthType && options.outboundAuthType !== 'NONE') { if (!options.credentialName) { diff --git a/src/cli/commands/remove/__tests__/remove-gateway-target.test.ts b/src/cli/commands/remove/__tests__/remove-gateway-target.test.ts index 00f01e9f..33c272fa 100644 --- a/src/cli/commands/remove/__tests__/remove-gateway-target.test.ts +++ b/src/cli/commands/remove/__tests__/remove-gateway-target.test.ts @@ -9,8 +9,6 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; describe.skip('remove gateway-target command', () => { let testDir: string; let projectDir: string; - const agentName = 'TestAgent'; - const runtimeToolName = 'RuntimeTool'; beforeAll(async () => { testDir = join(tmpdir(), `agentcore-remove-gateway-target-${randomUUID()}`); @@ -18,55 +16,11 @@ describe.skip('remove gateway-target command', () => { // Create project const projectName = 'RemoveGatewayTargetProj'; - let result = await runCLI(['create', '--name', projectName, '--no-agent'], testDir); + const result = await runCLI(['create', '--name', projectName, '--no-agent'], testDir); if (result.exitCode !== 0) { throw new Error(`Failed to create project: ${result.stdout} ${result.stderr}`); } projectDir = join(testDir, projectName); - - // Add agent - result = await runCLI( - [ - 'add', - 'agent', - '--name', - agentName, - '--language', - 'Python', - '--framework', - 'Strands', - '--model-provider', - 'Bedrock', - '--memory', - 'none', - '--json', - ], - projectDir - ); - if (result.exitCode !== 0) { - throw new Error(`Failed to create agent: ${result.stdout} ${result.stderr}`); - } - - // Add mcp-runtime tool - result = await runCLI( - [ - 'add', - 'gateway-target', - '--name', - runtimeToolName, - '--language', - 'Python', - '--exposure', - 'mcp-runtime', - '--agents', - agentName, - '--json', - ], - projectDir - ); - if (result.exitCode !== 0) { - throw new Error(`Failed to create runtime tool: ${result.stdout} ${result.stderr}`); - } }); afterAll(async () => { @@ -91,45 +45,6 @@ describe.skip('remove gateway-target command', () => { }); }); - describe('remove mcp-runtime tool', () => { - it('removes mcp-runtime tool and cleans up agent references', async () => { - // Add a temp tool to remove - const tempTool = `tempRt${Date.now()}`; - await runCLI( - [ - 'add', - 'gateway-target', - '--name', - tempTool, - '--language', - 'Python', - '--exposure', - 'mcp-runtime', - '--agents', - agentName, - '--json', - ], - projectDir - ); - - const result = await runCLI(['remove', 'gateway-target', '--name', tempTool, '--json'], projectDir); - expect(result.exitCode, `stdout: ${result.stdout}`).toBe(0); - const json = JSON.parse(result.stdout); - expect(json.success).toBe(true); - - // Verify tool is removed from mcp.json - const mcpSpec = JSON.parse(await readFile(join(projectDir, 'agentcore/mcp.json'), 'utf-8')); - const tool = mcpSpec.mcpRuntimeTools?.find((t: { name: string }) => t.name === tempTool); - expect(!tool, 'Tool should be removed from mcpRuntimeTools').toBeTruthy(); - - // Verify agent reference is cleaned up - const projectSpec = JSON.parse(await readFile(join(projectDir, 'agentcore/agentcore.json'), 'utf-8')); - const agent = projectSpec.agents.find((a: { name: string }) => a.name === agentName); - const hasRef = agent?.remoteTools?.some((rt: { mcpRuntimeName?: string }) => rt.mcpRuntimeName === tempTool); - expect(!hasRef, 'Agent should not have reference to removed tool').toBeTruthy(); - }); - }); - // Gateway disabled - skip behind-gateway tests until gateway feature is enabled describe.skip('remove behind-gateway tool', () => { it('removes behind-gateway tool from gateway targets', async () => { @@ -148,8 +63,6 @@ describe.skip('remove gateway-target command', () => { tempTool, '--language', 'Python', - '--exposure', - 'behind-gateway', '--gateway', tempGateway, '--host', diff --git a/src/cli/operations/mcp/create-mcp.ts b/src/cli/operations/mcp/create-mcp.ts index fe517515..4e9b7d56 100644 --- a/src/cli/operations/mcp/create-mcp.ts +++ b/src/cli/operations/mcp/create-mcp.ts @@ -3,9 +3,7 @@ import type { AgentCoreCliMcpDefs, AgentCoreGateway, AgentCoreGatewayTarget, - AgentCoreMcpRuntimeTool, AgentCoreMcpSpec, - CodeZipRuntimeConfig, DirectoryPath, FilePath, } from '../../../schema'; @@ -255,7 +253,7 @@ export async function createExternalGatewayTarget(config: AddGatewayTargetConfig } /** - * Create an MCP tool (MCP runtime or behind gateway). + * Create an MCP tool (behind gateway only). */ export async function createToolFromWizard(config: AddGatewayTargetConfig): Promise { validateGatewayTargetLanguage(config.language); @@ -280,117 +278,76 @@ export async function createToolFromWizard(config: AddGatewayTargetConfig): Prom ToolDefinitionSchema.parse(toolDef); } - if (config.exposure === 'mcp-runtime') { - // MCP Runtime tool - always AgentCoreRuntime (single tool) - // Build explicit CodeZipRuntimeConfig - no CLI-managed placeholders - const runtimeConfig: CodeZipRuntimeConfig = { - artifact: 'CodeZip', - pythonVersion: DEFAULT_PYTHON_VERSION, - name: config.name, - entrypoint: 'server.py:main' as FilePath, - codeLocation: config.sourcePath as DirectoryPath, - networkMode: 'PUBLIC', - }; - - // 'Other' language requires container config - not supported for mcp-runtime yet - if (config.language === 'Other') { - throw new Error('Language "Other" is not yet supported for MCP runtime tools. Use Python or TypeScript.'); - } - - const mcpRuntimeTool: AgentCoreMcpRuntimeTool = { - name: config.name, - toolDefinition: config.toolDefinition, - compute: { - host: 'AgentCoreRuntime', - implementation: { - path: config.sourcePath, - language: config.language, - handler: DEFAULT_HANDLER, - }, - runtime: runtimeConfig, - }, - }; - - const mcpRuntimeTools = mcpSpec.mcpRuntimeTools ?? []; - if (mcpRuntimeTools.some(tool => tool.name === mcpRuntimeTool.name)) { - throw new Error(`MCP runtime tool "${mcpRuntimeTool.name}" already exists.`); - } - mcpSpec.mcpRuntimeTools = [...mcpRuntimeTools, mcpRuntimeTool]; - - // Write mcp.json - await configIO.writeMcpSpec(mcpSpec); - } else { - // Behind gateway - if (!config.gateway) { - throw new Error('Gateway name is required for tools behind a gateway.'); - } + // Behind gateway + if (!config.gateway) { + throw new Error('Gateway name is required for tools behind a gateway.'); + } - const gateway = mcpSpec.agentCoreGateways.find(g => g.name === config.gateway); - if (!gateway) { - throw new Error(`Gateway "${config.gateway}" not found.`); - } + const gateway = mcpSpec.agentCoreGateways.find(g => g.name === config.gateway); + if (!gateway) { + throw new Error(`Gateway "${config.gateway}" not found.`); + } - // Check for duplicate target name - if (gateway.targets.some(t => t.name === config.name)) { - throw new Error(`Target "${config.name}" already exists in gateway "${gateway.name}".`); - } + // Check for duplicate target name + if (gateway.targets.some(t => t.name === config.name)) { + throw new Error(`Target "${config.name}" already exists in gateway "${gateway.name}".`); + } - // Check for duplicate tool names - for (const toolDef of toolDefs) { - for (const existingTarget of gateway.targets) { - if ((existingTarget.toolDefinitions ?? []).some(t => t.name === toolDef.name)) { - throw new Error(`Tool "${toolDef.name}" already exists in gateway "${gateway.name}".`); - } + // Check for duplicate tool names + for (const toolDef of toolDefs) { + for (const existingTarget of gateway.targets) { + if ((existingTarget.toolDefinitions ?? []).some(t => t.name === toolDef.name)) { + throw new Error(`Tool "${toolDef.name}" already exists in gateway "${gateway.name}".`); } } + } - // 'Other' language requires container config - not supported for gateway tools yet - if (config.language === 'Other') { - throw new Error('Language "Other" is not yet supported for gateway tools. Use Python or TypeScript.'); - } + // 'Other' language requires container config - not supported for gateway tools yet + if (config.language === 'Other') { + throw new Error('Language "Other" is not yet supported for gateway tools. Use Python or TypeScript.'); + } - // Create a single target with all tool definitions - const target: AgentCoreGatewayTarget = { - name: config.name, - targetType: config.host === 'AgentCoreRuntime' ? 'mcpServer' : 'lambda', - toolDefinitions: toolDefs, - compute: - config.host === 'Lambda' - ? { - host: 'Lambda', - implementation: { - path: config.sourcePath, - language: config.language, - handler: DEFAULT_HANDLER, - }, - ...(config.language === 'Python' - ? { pythonVersion: DEFAULT_PYTHON_VERSION } - : { nodeVersion: DEFAULT_NODE_VERSION }), - } - : { - host: 'AgentCoreRuntime', - implementation: { - path: config.sourcePath, - language: 'Python', - handler: 'server.py:main', - }, - runtime: { - artifact: 'CodeZip', - pythonVersion: DEFAULT_PYTHON_VERSION, - name: config.name, - entrypoint: 'server.py:main' as FilePath, - codeLocation: config.sourcePath as DirectoryPath, - networkMode: 'PUBLIC', - }, + // Create a single target with all tool definitions + const target: AgentCoreGatewayTarget = { + name: config.name, + targetType: config.host === 'AgentCoreRuntime' ? 'mcpServer' : 'lambda', + toolDefinitions: toolDefs, + compute: + config.host === 'Lambda' + ? { + host: 'Lambda', + implementation: { + path: config.sourcePath, + language: config.language, + handler: DEFAULT_HANDLER, + }, + ...(config.language === 'Python' + ? { pythonVersion: DEFAULT_PYTHON_VERSION } + : { nodeVersion: DEFAULT_NODE_VERSION }), + } + : { + host: 'AgentCoreRuntime', + implementation: { + path: config.sourcePath, + language: 'Python', + handler: 'server.py:main', }, - ...(config.outboundAuth && { outboundAuth: config.outboundAuth }), - }; + runtime: { + artifact: 'CodeZip', + pythonVersion: DEFAULT_PYTHON_VERSION, + name: config.name, + entrypoint: 'server.py:main' as FilePath, + codeLocation: config.sourcePath as DirectoryPath, + networkMode: 'PUBLIC', + }, + }, + ...(config.outboundAuth && { outboundAuth: config.outboundAuth }), + }; - gateway.targets.push(target); + gateway.targets.push(target); - // Write mcp.json for gateway case - await configIO.writeMcpSpec(mcpSpec); - } + // Write mcp.json for gateway case + await configIO.writeMcpSpec(mcpSpec); // Update mcp-defs.json with all tool definitions const mcpDefsPath = resolveMcpDefsPath(); diff --git a/src/cli/operations/remove/remove-gateway-target.ts b/src/cli/operations/remove/remove-gateway-target.ts index 5d37a3e6..f3aacb5d 100644 --- a/src/cli/operations/remove/remove-gateway-target.ts +++ b/src/cli/operations/remove/remove-gateway-target.ts @@ -10,7 +10,7 @@ import { join } from 'path'; */ export interface RemovableGatewayTarget { name: string; - type: 'mcp-runtime' | 'gateway-target'; + type: 'gateway-target'; gatewayName?: string; } @@ -26,11 +26,6 @@ export async function getRemovableGatewayTargets(): Promise t.name === tool.name); - if (!mcpTool) { - throw new Error(`MCP Runtime tool "${tool.name}" not found.`); - } + // Gateway target + const gateway = mcpSpec.agentCoreGateways.find(g => g.name === tool.gatewayName); + if (!gateway) { + throw new Error(`Gateway "${tool.gatewayName}" not found.`); + } - summary.push(`Removing MCP Runtime tool: ${tool.name}`); + const target = gateway.targets.find(t => t.name === tool.name); + if (!target) { + throw new Error(`Target "${tool.name}" not found in gateway "${tool.gatewayName}".`); + } - // Check for directory to delete - const implementation = mcpTool.compute.implementation; - const toolPath = 'path' in implementation ? implementation.path : undefined; - if (toolPath) { - const toolDir = join(projectRoot, toolPath); - if (existsSync(toolDir)) { - directoriesToDelete.push(toolDir); - summary.push(`Deleting directory: ${toolPath}`); - } - } + summary.push(`Removing gateway target: ${tool.name} (from ${tool.gatewayName})`); - // Tool definition in mcp-defs - if (mcpDefs.tools[mcpTool.toolDefinition.name]) { - summary.push(`Removing tool definition: ${mcpTool.toolDefinition.name}`); - } - } else { - // Gateway target - const gateway = mcpSpec.agentCoreGateways.find(g => g.name === tool.gatewayName); - if (!gateway) { - throw new Error(`Gateway "${tool.gatewayName}" not found.`); - } - - const target = gateway.targets.find(t => t.name === tool.name); - if (!target) { - throw new Error(`Target "${tool.name}" not found in gateway "${tool.gatewayName}".`); - } - - summary.push(`Removing gateway target: ${tool.name} (from ${tool.gatewayName})`); - - // Check for directory to delete - if (target.compute?.implementation && 'path' in target.compute.implementation) { - const toolPath = target.compute.implementation.path; - const toolDir = join(projectRoot, toolPath); - if (existsSync(toolDir)) { - directoriesToDelete.push(toolDir); - summary.push(`Deleting directory: ${toolPath}`); - } + // Check for directory to delete + if (target.compute?.implementation && 'path' in target.compute.implementation) { + const toolPath = target.compute.implementation.path; + const toolDir = join(projectRoot, toolPath); + if (existsSync(toolDir)) { + directoriesToDelete.push(toolDir); + summary.push(`Deleting directory: ${toolPath}`); } + } - // Tool definitions in mcp-defs - for (const toolDef of target.toolDefinitions ?? []) { - if (mcpDefs.tools[toolDef.name]) { - summary.push(`Removing tool definition: ${toolDef.name}`); - } + // Tool definitions in mcp-defs + for (const toolDef of target.toolDefinitions ?? []) { + if (mcpDefs.tools[toolDef.name]) { + summary.push(`Removing tool definition: ${toolDef.name}`); } } @@ -140,13 +110,6 @@ export async function previewRemoveGatewayTarget(tool: RemovableGatewayTarget): * Compute the MCP spec after removing a tool. */ function computeRemovedToolMcpSpec(mcpSpec: AgentCoreMcpSpec, tool: RemovableGatewayTarget): AgentCoreMcpSpec { - if (tool.type === 'mcp-runtime') { - return { - ...mcpSpec, - mcpRuntimeTools: (mcpSpec.mcpRuntimeTools ?? []).filter(t => t.name !== tool.name), - }; - } - // Gateway target return { ...mcpSpec, @@ -170,18 +133,11 @@ function computeRemovedToolMcpDefs( ): AgentCoreCliMcpDefs { const toolNamesToRemove: string[] = []; - if (tool.type === 'mcp-runtime') { - const mcpTool = mcpSpec.mcpRuntimeTools?.find(t => t.name === tool.name); - if (mcpTool) { - toolNamesToRemove.push(mcpTool.toolDefinition.name); - } - } else { - const gateway = mcpSpec.agentCoreGateways.find(g => g.name === tool.gatewayName); - const target = gateway?.targets.find(t => t.name === tool.name); - if (target) { - for (const toolDef of target.toolDefinitions ?? []) { - toolNamesToRemove.push(toolDef.name); - } + const gateway = mcpSpec.agentCoreGateways.find(g => g.name === tool.gatewayName); + const target = gateway?.targets.find(t => t.name === tool.name); + if (target) { + for (const toolDef of target.toolDefinitions ?? []) { + toolNamesToRemove.push(toolDef.name); } } @@ -206,25 +162,16 @@ export async function removeGatewayTarget(tool: RemovableGatewayTarget): Promise // Find the tool path for deletion let toolPath: string | undefined; - if (tool.type === 'mcp-runtime') { - const mcpTool = mcpSpec.mcpRuntimeTools?.find(t => t.name === tool.name); - if (!mcpTool) { - return { ok: false, error: `MCP Runtime tool "${tool.name}" not found.` }; - } - const impl = mcpTool.compute.implementation; - toolPath = 'path' in impl ? impl.path : undefined; - } else { - const gateway = mcpSpec.agentCoreGateways.find(g => g.name === tool.gatewayName); - if (!gateway) { - return { ok: false, error: `Gateway "${tool.gatewayName}" not found.` }; - } - const target = gateway.targets.find(t => t.name === tool.name); - if (!target) { - return { ok: false, error: `Target "${tool.name}" not found in gateway "${tool.gatewayName}".` }; - } - if (target.compute?.implementation && 'path' in target.compute.implementation) { - toolPath = target.compute.implementation.path; - } + const gateway = mcpSpec.agentCoreGateways.find(g => g.name === tool.gatewayName); + if (!gateway) { + return { ok: false, error: `Gateway "${tool.gatewayName}" not found.` }; + } + const target = gateway.targets.find(t => t.name === tool.name); + if (!target) { + return { ok: false, error: `Target "${tool.name}" not found in gateway "${tool.gatewayName}".` }; + } + if (target.compute?.implementation && 'path' in target.compute.implementation) { + toolPath = target.compute.implementation.path; } // Update MCP spec diff --git a/src/cli/tui/screens/add/AddFlow.tsx b/src/cli/tui/screens/add/AddFlow.tsx index 921da218..0f2b4fab 100644 --- a/src/cli/tui/screens/add/AddFlow.tsx +++ b/src/cli/tui/screens/add/AddFlow.tsx @@ -339,7 +339,6 @@ export function AddFlow(props: AddFlowProps) { return ( setFlow({ name: 'select' })} onDev={props.onDev} diff --git a/src/cli/tui/screens/mcp/AddGatewayFlow.tsx b/src/cli/tui/screens/mcp/AddGatewayFlow.tsx index 8eb895b3..36191b84 100644 --- a/src/cli/tui/screens/mcp/AddGatewayFlow.tsx +++ b/src/cli/tui/screens/mcp/AddGatewayFlow.tsx @@ -332,7 +332,7 @@ export function AddGatewayFlow({ void; onBack: () => void; /** Called when user selects dev from success screen to run agent locally */ @@ -40,7 +38,6 @@ const MODE_OPTIONS: SelectableItem[] = [ export function AddGatewayTargetFlow({ isInteractive = true, - existingAgents, onExit, onBack, onDev, @@ -166,7 +163,6 @@ export function AddGatewayTargetFlow({ return ( setFlow({ name: 'mode-select' })} diff --git a/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx b/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx index 6a447420..f8a2522e 100644 --- a/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx +++ b/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx @@ -1,21 +1,12 @@ import { ToolNameSchema } from '../../../../schema'; -import { - ConfirmReview, - Panel, - Screen, - StepIndicator, - TextInput, - WizardMultiSelect, - WizardSelect, -} from '../../components'; +import { ConfirmReview, Panel, Screen, StepIndicator, TextInput, WizardSelect } from '../../components'; import type { SelectableItem } from '../../components'; import { HELP_TEXT } from '../../constants'; -import { useListNavigation, useMultiSelectNavigation } from '../../hooks'; +import { useListNavigation } from '../../hooks'; import { generateUniqueName } from '../../utils'; -import type { AddGatewayTargetConfig, ComputeHost, ExposureMode, TargetLanguage } from './types'; +import type { AddGatewayTargetConfig, ComputeHost, TargetLanguage } from './types'; import { COMPUTE_HOST_OPTIONS, - EXPOSURE_MODE_OPTIONS, MCP_TOOL_STEP_LABELS, SKIP_FOR_NOW, SOURCE_OPTIONS, @@ -27,7 +18,6 @@ import React, { useMemo } from 'react'; interface AddGatewayTargetScreenProps { existingGateways: string[]; - existingAgents: string[]; existingToolNames: string[]; onComplete: (config: AddGatewayTargetConfig) => void; onExit: () => void; @@ -35,12 +25,11 @@ interface AddGatewayTargetScreenProps { export function AddGatewayTargetScreen({ existingGateways, - existingAgents, existingToolNames, onComplete, onExit, }: AddGatewayTargetScreenProps) { - const wizard = useAddGatewayTargetWizard(existingGateways, existingAgents); + const wizard = useAddGatewayTargetWizard(existingGateways); const sourceItems: SelectableItem[] = useMemo( () => SOURCE_OPTIONS.map(o => ({ id: o.id, title: o.title, description: o.description })), @@ -52,17 +41,6 @@ export function AddGatewayTargetScreen({ [] ); - const exposureItems: SelectableItem[] = useMemo( - () => - EXPOSURE_MODE_OPTIONS.map(o => ({ - id: o.id, - title: o.title, - description: o.description, - disabled: 'disabled' in o ? Boolean(o.disabled) : undefined, - })), - [] - ); - const gatewayItems: SelectableItem[] = useMemo( () => [ ...existingGateways.map(g => ({ id: g, title: g })), @@ -76,18 +54,13 @@ export function AddGatewayTargetScreen({ [] ); - const agentItems: SelectableItem[] = useMemo(() => existingAgents.map(a => ({ id: a, title: a })), [existingAgents]); - const isSourceStep = wizard.step === 'source'; const isLanguageStep = wizard.step === 'language'; - const isExposureStep = wizard.step === 'exposure'; - const isAgentsStep = wizard.step === 'agents'; const isGatewayStep = wizard.step === 'gateway'; const isHostStep = wizard.step === 'host'; const isTextStep = wizard.step === 'name' || wizard.step === 'endpoint'; const isConfirmStep = wizard.step === 'confirm'; const noGatewaysAvailable = isGatewayStep && existingGateways.length === 0; - const noAgentsAvailable = isAgentsStep && existingAgents.length === 0; const sourceNav = useListNavigation({ items: sourceItems, @@ -103,14 +76,6 @@ export function AddGatewayTargetScreen({ isActive: isLanguageStep, }); - const exposureNav = useListNavigation({ - items: exposureItems, - onSelect: item => wizard.setExposure(item.id as ExposureMode), - onExit: () => wizard.goBack(), - isActive: isExposureStep, - isDisabled: item => item.disabled === true, - }); - const gatewayNav = useListNavigation({ items: gatewayItems, onSelect: item => wizard.setGateway(item.id), @@ -125,14 +90,6 @@ export function AddGatewayTargetScreen({ isActive: isHostStep, }); - const agentsNav = useMultiSelectNavigation({ - items: agentItems, - getId: item => item.id, - onConfirm: selected => wizard.setAgents(selected), - onExit: () => wizard.goBack(), - isActive: isAgentsStep && !noAgentsAvailable, - }); - useListNavigation({ items: [{ id: 'confirm', title: 'Confirm' }], onSelect: () => onComplete(wizard.config), @@ -144,14 +101,10 @@ export function AddGatewayTargetScreen({ ? HELP_TEXT.CONFIRM_CANCEL : isTextStep ? HELP_TEXT.TEXT_INPUT - : isAgentsStep - ? HELP_TEXT.MULTI_SELECT - : HELP_TEXT.NAVIGATE_SELECT; + : HELP_TEXT.NAVIGATE_SELECT; const headerContent = ; - const isMcpRuntime = wizard.config.exposure === 'mcp-runtime'; - return ( @@ -168,15 +121,6 @@ export function AddGatewayTargetScreen({ )} - {isExposureStep && ( - - )} - {isGatewayStep && !noGatewaysAvailable && ( } - {isAgentsStep && !noAgentsAvailable && ( - - )} - - {noAgentsAvailable && } - {isHostStep && ( 0 - ? [{ label: 'Agents', value: wizard.config.selectedAgents.join(', ') }] - : []), - ...(!isMcpRuntime && wizard.config.gateway ? [{ label: 'Gateway', value: wizard.config.gateway }] : []), - ...(!isMcpRuntime && !wizard.config.gateway - ? [{ label: 'Gateway', value: '(none - assign later)' }] - : []), + ...(wizard.config.gateway ? [{ label: 'Gateway', value: wizard.config.gateway }] : []), + ...(!wizard.config.gateway ? [{ label: 'Gateway', value: '(none - assign later)' }] : []), ...(wizard.config.source === 'create-new' ? [{ label: 'Host', value: wizard.config.host }] : []), ...(wizard.config.source === 'create-new' ? [{ label: 'Source', value: wizard.config.sourcePath }] : []), ]} @@ -279,16 +203,3 @@ function NoGatewaysMessage() { ); } - -function NoAgentsMessage() { - return ( - - No agents found - Create an agent first to attach MCP runtime tools. - You can still create the tool and attach agents later. - - Enter to continue without agents · Esc back - - - ); -} diff --git a/src/cli/tui/screens/mcp/index.ts b/src/cli/tui/screens/mcp/index.ts index c7e6956a..4f7e44b1 100644 --- a/src/cli/tui/screens/mcp/index.ts +++ b/src/cli/tui/screens/mcp/index.ts @@ -10,5 +10,4 @@ export type { AddGatewayTargetConfig, AddGatewayTargetStep, ComputeHost, - ExposureMode, } from './types'; diff --git a/src/cli/tui/screens/mcp/types.ts b/src/cli/tui/screens/mcp/types.ts index 6307e77b..8a5ffeaa 100644 --- a/src/cli/tui/screens/mcp/types.ts +++ b/src/cli/tui/screens/mcp/types.ts @@ -33,30 +33,17 @@ export const GATEWAY_STEP_LABELS: Record = { // MCP Tool Flow Types // ───────────────────────────────────────────────────────────────────────────── -export type ExposureMode = 'mcp-runtime' | 'behind-gateway'; - export type ComputeHost = 'Lambda' | 'AgentCoreRuntime'; /** * MCP tool wizard steps. * - name: Tool name input * - language: Target language (Python or TypeScript) - * - exposure: MCP Runtime (standalone) or behind-gateway - * - agents: Select agents to attach (only if mcp-runtime) - * - gateway: Select existing gateway (only if behind-gateway) - * - host: Select compute host (only if behind-gateway) + * - gateway: Select existing gateway + * - host: Select compute host * - confirm: Review and confirm */ -export type AddGatewayTargetStep = - | 'name' - | 'source' - | 'endpoint' - | 'language' - | 'exposure' - | 'agents' - | 'gateway' - | 'host' - | 'confirm'; +export type AddGatewayTargetStep = 'name' | 'source' | 'endpoint' | 'language' | 'gateway' | 'host' | 'confirm'; export type TargetLanguage = 'Python' | 'TypeScript' | 'Other'; @@ -65,19 +52,16 @@ export interface AddGatewayTargetConfig { description: string; sourcePath: string; language: TargetLanguage; - exposure: ExposureMode; /** Source type for external endpoints */ source?: 'existing-endpoint' | 'create-new'; /** External endpoint URL */ endpoint?: string; - /** Gateway name (only when exposure = behind-gateway) */ + /** Gateway name */ gateway?: string; - /** Compute host (AgentCoreRuntime for mcp-runtime, Lambda or AgentCoreRuntime for behind-gateway) */ + /** Compute host (Lambda or AgentCoreRuntime) */ host: ComputeHost; /** Derived tool definition */ toolDefinition: ToolDefinition; - /** Agent names to attach (only when exposure = mcp-runtime) */ - selectedAgents: string[]; /** Outbound auth configuration */ outboundAuth?: { type: 'OAUTH' | 'API_KEY' | 'NONE'; @@ -91,8 +75,6 @@ export const MCP_TOOL_STEP_LABELS: Record = { source: 'Source', endpoint: 'Endpoint', language: 'Language', - exposure: 'Exposure', - agents: 'Agents', gateway: 'Gateway', host: 'Host', confirm: 'Confirm', @@ -121,15 +103,6 @@ export const TARGET_LANGUAGE_OPTIONS = [ { id: 'Other', title: 'Other', description: 'Container-based implementation' }, ] as const; -export const EXPOSURE_MODE_OPTIONS = [ - { id: 'mcp-runtime', title: 'MCP Runtime', description: 'Deploy as AgentCore MCP Runtime (select agents to attach)' }, - { - id: 'behind-gateway', - title: 'Behind Gateway', - description: 'Route through AgentCore Gateway', - }, -] as const; - export const COMPUTE_HOST_OPTIONS = [ { id: 'Lambda', title: 'Lambda', description: 'AWS Lambda function' }, { id: 'AgentCoreRuntime', title: 'AgentCore Runtime', description: 'AgentCore Runtime (Python only)' }, diff --git a/src/cli/tui/screens/mcp/useAddGatewayTargetWizard.ts b/src/cli/tui/screens/mcp/useAddGatewayTargetWizard.ts index e5934fd0..29aa54ac 100644 --- a/src/cli/tui/screens/mcp/useAddGatewayTargetWizard.ts +++ b/src/cli/tui/screens/mcp/useAddGatewayTargetWizard.ts @@ -1,23 +1,19 @@ import { APP_DIR, MCP_APP_SUBDIR } from '../../../../lib'; import type { ToolDefinition } from '../../../../schema'; -import type { AddGatewayTargetConfig, AddGatewayTargetStep, ComputeHost, ExposureMode, TargetLanguage } from './types'; +import type { AddGatewayTargetConfig, AddGatewayTargetStep, ComputeHost, TargetLanguage } from './types'; import { SKIP_FOR_NOW } from './types'; import { useCallback, useMemo, useState } from 'react'; /** - * Dynamic steps based on exposure mode and source. + * Dynamic steps based on source. * - Existing endpoint: name → source → endpoint → gateway → confirm - * - Create new MCP Runtime: name → source → language → exposure → agents → confirm - * - Create new Behind gateway: name → source → language → exposure → gateway → host → confirm + * - Create new: name → source → language → gateway → host → confirm */ -function getSteps(exposure: ExposureMode, source?: 'existing-endpoint' | 'create-new'): AddGatewayTargetStep[] { +function getSteps(source?: 'existing-endpoint' | 'create-new'): AddGatewayTargetStep[] { if (source === 'existing-endpoint') { return ['name', 'source', 'endpoint', 'gateway', 'confirm']; } - if (exposure === 'mcp-runtime') { - return ['name', 'source', 'language', 'exposure', 'agents', 'confirm']; - } - return ['name', 'source', 'language', 'exposure', 'gateway', 'host', 'confirm']; + return ['name', 'source', 'language', 'gateway', 'host', 'confirm']; } function deriveToolDefinition(name: string): ToolDefinition { @@ -34,27 +30,25 @@ function getDefaultConfig(): AddGatewayTargetConfig { description: '', sourcePath: '', language: 'Python', - exposure: 'mcp-runtime', - host: 'AgentCoreRuntime', + host: 'Lambda', toolDefinition: deriveToolDefinition(''), - selectedAgents: [], }; } -export function useAddGatewayTargetWizard(existingGateways: string[] = [], existingAgents: string[] = []) { +export function useAddGatewayTargetWizard(existingGateways: string[] = []) { const [config, setConfig] = useState(getDefaultConfig); const [step, setStep] = useState('name'); - const steps = useMemo(() => getSteps(config.exposure, config.source), [config.exposure, config.source]); + const steps = useMemo(() => getSteps(config.source), [config.source]); const currentIndex = steps.indexOf(step); const goBack = useCallback(() => { - // Recalculate steps in case exposure or source changed - const currentSteps = getSteps(config.exposure, config.source); + // Recalculate steps in case source changed + const currentSteps = getSteps(config.source); const idx = currentSteps.indexOf(step); const prevStep = currentSteps[idx - 1]; if (prevStep) setStep(prevStep); - }, [config.exposure, config.source, step]); + }, [config.source, step]); const setName = useCallback((name: string) => { setConfig(c => ({ @@ -92,37 +86,7 @@ export function useAddGatewayTargetWizard(existingGateways: string[] = [], exist ...c, language, })); - setStep('exposure'); - }, []); - - const setExposure = useCallback((exposure: ExposureMode) => { - if (exposure === 'mcp-runtime') { - // MCP Runtime: host is always AgentCoreRuntime, go to agents selection - setConfig(c => ({ - ...c, - exposure, - host: 'AgentCoreRuntime', - gateway: undefined, - })); - setStep('agents'); - } else { - // Behind gateway: need to select gateway next - setConfig(c => ({ - ...c, - exposure, - selectedAgents: [], // Clear selected agents when switching to gateway mode - })); - // If no gateways exist, we should handle this in the UI - setStep('gateway'); - } - }, []); - - const setAgents = useCallback((agents: string[]) => { - setConfig(c => ({ - ...c, - selectedAgents: agents, - })); - setStep('confirm'); + setStep('gateway'); }, []); const setGateway = useCallback((gateway: string) => { @@ -157,14 +121,11 @@ export function useAddGatewayTargetWizard(existingGateways: string[] = [], exist steps, currentIndex, existingGateways, - existingAgents, goBack, setName, setSource, setEndpoint, setLanguage, - setExposure, - setAgents, setGateway, setHost, reset, diff --git a/src/cli/tui/screens/remove/RemoveGatewayTargetScreen.tsx b/src/cli/tui/screens/remove/RemoveGatewayTargetScreen.tsx index e0253ddb..e61d599c 100644 --- a/src/cli/tui/screens/remove/RemoveGatewayTargetScreen.tsx +++ b/src/cli/tui/screens/remove/RemoveGatewayTargetScreen.tsx @@ -15,7 +15,7 @@ export function RemoveGatewayTargetScreen({ tools, onSelect, onExit }: RemoveGat const items = tools.map(tool => ({ id: tool.name, title: tool.name, - description: tool.type === 'mcp-runtime' ? 'MCP Runtime tool' : `Gateway target (${tool.gatewayName})`, + description: `Gateway target (${tool.gatewayName})`, })); // Create a map for quick lookup diff --git a/src/cli/tui/screens/schema/McpGuidedEditor.tsx b/src/cli/tui/screens/schema/McpGuidedEditor.tsx index 95977e9a..d0b0af33 100644 --- a/src/cli/tui/screens/schema/McpGuidedEditor.tsx +++ b/src/cli/tui/screens/schema/McpGuidedEditor.tsx @@ -1,7 +1,6 @@ import { type AgentCoreGateway, type AgentCoreGatewayTarget, - type AgentCoreMcpRuntimeTool, type AgentCoreMcpSpec, AgentCoreMcpSpecSchema, GatewayNameSchema, @@ -79,7 +78,7 @@ export function McpGuidedEditor(props: McpGuidedEditorProps) { ); } -type ViewMode = 'gateways' | 'mcp-runtime'; +// Gateways view is the only view mode type ScreenMode = 'list' | 'confirm-exit' | 'edit-item' | 'edit-field' | 'edit-targets' | 'edit-target-field'; function McpEditorBody(props: { @@ -91,13 +90,10 @@ function McpEditorBody(props: { onRequestAdd?: () => void; }) { const [gateways, setGateways] = useState(props.initialSpec.agentCoreGateways); - const [mcpRuntimeTools, setMcpRuntimeTools] = useState( - props.initialSpec.mcpRuntimeTools ?? [] - ); const [unassignedTargets, _setUnassignedTargets] = useState( props.initialSpec.unassignedTargets ?? [] ); - const [viewMode, setViewMode] = useState('gateways'); + // Only gateways view mode const [selectedIndex, setSelectedIndex] = useState(0); const [expandedIndex, setExpandedIndex] = useState(null); const [dirty, setDirty] = useState(false); @@ -110,18 +106,15 @@ function McpEditorBody(props: { const [selectedTargetIndex, setSelectedTargetIndex] = useState(0); const [editingTargetFieldId, setEditingTargetFieldId] = useState(null); - const hasMcpRuntimeTools = mcpRuntimeTools.length > 0 || (props.initialSpec.mcpRuntimeTools?.length ?? 0) > 0; - // Define editable fields for the current item - const currentGateway = viewMode === 'gateways' ? gateways[selectedIndex] : null; + const currentGateway = gateways[selectedIndex]; const targetCount = currentGateway?.targets?.length ?? 0; const gatewayFields = [ { id: 'name', label: 'Name' }, { id: 'description', label: 'Description' }, { id: 'targets', label: `Targets (${targetCount})` }, ]; - const mcpRuntimeFields = [{ id: 'name', label: 'Name' }]; - const currentFields = viewMode === 'gateways' ? gatewayFields : mcpRuntimeFields; + const currentFields = gatewayFields; // Target fields const currentTarget = currentGateway?.targets?.[selectedTargetIndex]; @@ -135,7 +128,6 @@ function McpEditorBody(props: { async function commitChanges() { const spec: AgentCoreMcpSpec & { unassignedTargets?: AgentCoreGatewayTarget[] } = { agentCoreGateways: gateways, - ...(mcpRuntimeTools.length > 0 ? { mcpRuntimeTools: mcpRuntimeTools } : {}), ...(unassignedTargets.length > 0 ? { unassignedTargets: unassignedTargets } : {}), }; const content = JSON.stringify(spec, null, 2); @@ -239,14 +231,6 @@ function McpEditorBody(props: { return; } - // Tab to switch between gateways and mcp-runtime views - if (key.tab && hasMcpRuntimeTools) { - setViewMode(prev => (prev === 'gateways' ? 'mcp-runtime' : 'gateways')); - setSelectedIndex(0); - setExpandedIndex(null); - return; - } - // A to add (works in both views) if (input.toLowerCase() === 'a' && props.onRequestAdd) { props.onRequestAdd(); @@ -254,7 +238,7 @@ function McpEditorBody(props: { } // View-specific navigation and actions - const items = viewMode === 'gateways' ? gateways : mcpRuntimeTools; + const items = gateways; const itemCount = items.length; if (key.upArrow && itemCount > 0) { @@ -282,13 +266,8 @@ function McpEditorBody(props: { // D to delete if (input.toLowerCase() === 'd' && itemCount > 0) { - if (viewMode === 'gateways') { - const next = gateways.filter((_, idx) => idx !== selectedIndex); - setGateways(next); - } else { - const next = mcpRuntimeTools.filter((_, idx) => idx !== selectedIndex); - setMcpRuntimeTools(next); - } + const next = gateways.filter((_, idx) => idx !== selectedIndex); + setGateways(next); setSelectedIndex(prev => Math.max(0, Math.min(prev, itemCount - 2))); setExpandedIndex(null); setDirty(true); @@ -298,24 +277,21 @@ function McpEditorBody(props: { // Edit item screen - shows list of editable fields if (screenMode === 'edit-item') { - const currentGateway = viewMode === 'gateways' ? gateways[selectedIndex] : null; - const currentTool = viewMode === 'mcp-runtime' ? mcpRuntimeTools[selectedIndex] : null; - const itemName = currentGateway?.name ?? currentTool?.name ?? 'Unknown'; + const currentGateway = gateways[selectedIndex]; + const itemName = currentGateway?.name ?? 'Unknown'; return ( -
+
↑↓ navigate · Enter edit · Esc back {currentFields.map((field, idx) => { const selected = idx === editFieldIndex; let value = ''; - if (viewMode === 'gateways' && currentGateway) { + if (currentGateway) { if (field.id === 'name') value = currentGateway.name; if (field.id === 'description') value = currentGateway.description ?? ''; - } else if (currentTool) { - if (field.id === 'name') value = currentTool.name; } return ( @@ -337,8 +313,7 @@ function McpEditorBody(props: { // Edit field screen - text input for the selected field if (screenMode === 'edit-field' && editingFieldId) { - const currentGateway = viewMode === 'gateways' ? gateways[selectedIndex] : null; - const currentTool = viewMode === 'mcp-runtime' ? mcpRuntimeTools[selectedIndex] : null; + const currentGateway = gateways[selectedIndex]; const field = currentFields.find(f => f.id === editingFieldId); if (!field) { @@ -347,51 +322,35 @@ function McpEditorBody(props: { } let initialValue = ''; - if (viewMode === 'gateways' && currentGateway) { + if (currentGateway) { if (editingFieldId === 'name') initialValue = currentGateway.name; if (editingFieldId === 'description') initialValue = currentGateway.description ?? ''; - } else if (currentTool) { - if (editingFieldId === 'name') initialValue = currentTool.name; } const handleSubmit = (value: string) => { - if (viewMode === 'gateways') { - if (editingFieldId === 'name') { - const next = gateways.map((g, idx) => (idx === selectedIndex ? { ...g, name: value } : g)); - setGateways(next); - } else if (editingFieldId === 'description') { - const next = gateways.map((g, idx) => - idx === selectedIndex ? { ...g, description: value || undefined } : g - ); - setGateways(next); - } - } else { - if (editingFieldId === 'name') { - const next = mcpRuntimeTools.map((t, idx) => (idx === selectedIndex ? { ...t, name: value } : t)); - setMcpRuntimeTools(next); - } + if (editingFieldId === 'name') { + const next = gateways.map((g, idx) => (idx === selectedIndex ? { ...g, name: value } : g)); + setGateways(next); + } else if (editingFieldId === 'description') { + const next = gateways.map((g, idx) => (idx === selectedIndex ? { ...g, description: value || undefined } : g)); + setGateways(next); } setDirty(true); setEditingFieldId(null); setScreenMode('edit-item'); }; - const isGatewayName = viewMode === 'gateways' && editingFieldId === 'name'; - const isToolName = viewMode === 'mcp-runtime' && editingFieldId === 'name'; + const isGatewayName = editingFieldId === 'name'; // Get existing names (excluding current) for uniqueness check let existingNames: string[] = []; if (isGatewayName) { existingNames = gateways.filter((_, idx) => idx !== selectedIndex).map(g => g.name); - } else if (isToolName) { - existingNames = mcpRuntimeTools.filter((_, idx) => idx !== selectedIndex).map(t => t.name); } - const customValidation = - isGatewayName || isToolName - ? (value: string) => - !existingNames.includes(value) || `${isGatewayName ? 'Gateway' : 'Tool'} name already exists` - : undefined; + const customValidation = isGatewayName + ? (value: string) => !existingNames.includes(value) || 'Gateway name already exists' + : undefined; return ( @@ -416,7 +375,7 @@ function McpEditorBody(props: { // Edit targets screen - shows list of targets in the current gateway if (screenMode === 'edit-targets') { - const gateway = viewMode === 'gateways' ? gateways[selectedIndex] : null; + const gateway = gateways[selectedIndex]; const targets = gateway?.targets ?? []; return ( @@ -456,7 +415,7 @@ function McpEditorBody(props: { // Edit target field screen - text input for the selected target field if (screenMode === 'edit-target-field' && editingTargetFieldId) { - const gateway = viewMode === 'gateways' ? gateways[selectedIndex] : null; + const gateway = gateways[selectedIndex]; const target = gateway?.targets?.[selectedTargetIndex]; const field = targetFields.find(f => f.id === editingTargetFieldId); @@ -478,7 +437,7 @@ function McpEditorBody(props: { } const handleSubmit = (value: string) => { - if (viewMode === 'gateways' && gateway) { + if (gateway) { const updatedTargets = [...(gateway.targets ?? [])]; const targetToUpdate = updatedTargets[selectedTargetIndex]; if (targetToUpdate) { @@ -546,7 +505,6 @@ function McpEditorBody(props: { if (screenMode === 'confirm-exit') { const spec: AgentCoreMcpSpec & { unassignedTargets?: AgentCoreGatewayTarget[] } = { agentCoreGateways: gateways, - ...(mcpRuntimeTools.length > 0 ? { mcpRuntimeTools: mcpRuntimeTools } : {}), ...(unassignedTargets.length > 0 ? { unassignedTargets: unassignedTargets } : {}), }; const currentText = JSON.stringify(spec, null, 2); @@ -595,108 +553,58 @@ function McpEditorBody(props: {
- - A add · D del · Space expand · Enter edit{hasMcpRuntimeTools ? ' · Tab switch' : ''} · Esc back - + A add · D del · Space expand · Enter edit · Esc back - {/* Tab bar */} - {hasMcpRuntimeTools && ( - - - [Gateways] - - - [MCP Runtime] - - - )} - - {viewMode === 'gateways' ? ( - - {gateways.length === 0 ? ( - No gateways configured. Press A to add one. - ) : ( - - {gateways.map((gateway, idx) => { - const selected = idx === selectedIndex; - const expanded = expandedIndex === idx; - const targetCount = gateway.targets?.length ?? 0; - return ( - - - {selected ? '>' : ' '} - {expanded ? '▼' : '▶'} - - {gateway.name} - - - ({targetCount} {targetCount === 1 ? 'target' : 'targets'}) - - {gateway.description && · {gateway.description}} - - {expanded && ( - - {targetCount === 0 ? ( - - No targets defined - - ) : ( - gateway.targets.map((target, tIdx) => ( - - · - {target.name ?? `Target ${tIdx + 1}`} - - ({target.toolDefinitions?.length ?? 0} tools ·{' '} - {target.compute?.host ?? target.targetType}) - - - )) - )} - - )} + + {gateways.length === 0 ? ( + No gateways configured. Press A to add one. + ) : ( + + {gateways.map((gateway, idx) => { + const selected = idx === selectedIndex; + const expanded = expandedIndex === idx; + const targetCount = gateway.targets?.length ?? 0; + return ( + + + {selected ? '>' : ' '} + {expanded ? '▼' : '▶'} + + {gateway.name} + + + ({targetCount} {targetCount === 1 ? 'target' : 'targets'}) + + {gateway.description && · {gateway.description}} - ); - })} - - )} - - ) : ( - - {mcpRuntimeTools.length === 0 ? ( - No MCP runtime tools configured. - ) : ( - - {mcpRuntimeTools.map((tool, idx) => { - const selected = idx === selectedIndex; - const expanded = expandedIndex === idx; - return ( - - - {selected ? '>' : ' '} - {expanded ? '▼' : '▶'} - - {tool.name} - - [{tool.compute.host}] + {expanded && ( + + {targetCount === 0 ? ( + + No targets defined + + ) : ( + gateway.targets.map((target, tIdx) => ( + + · + {target.name ?? `Target ${tIdx + 1}`} + + ({target.toolDefinitions?.length ?? 0} tools ·{' '} + {target.compute?.host ?? target.targetType}) + + + )) + )} - {expanded && ( - - Tool: {tool.toolDefinition?.name ?? '(unnamed)'} - Language: {tool.compute.implementation.language} - {'handler' in tool.compute.implementation && ( - Handler: {tool.compute.implementation.handler} - )} - - )} - - ); - })} - - )} - - )} + )} + + ); + })} + + )} + {/* Unassigned Targets */} From 6eba69374f576d42349b15b4cdecaf09f563c268 Mon Sep 17 00:00:00 2001 From: Aidan Daly <99039782+aidandaly24@users.noreply.github.com> Date: Tue, 24 Feb 2026 09:43:05 -0500 Subject: [PATCH 07/28] test: add unit tests for Batches 1-3 gateway functionality (#415) Comprehensive test coverage for MCP Gateway Phase 1 Batches 1-3: - Schema validation: gateway targets, outbound auth, credentials, deployed state - OAuth credential provider: CRUD operations, conflict handling, error paths - Pre-deploy identity: OAuth setup, credential collection, env var mapping - CLI validation: existing-endpoint path, credential validation - Deploy outputs: buildDeployedState with credentials, parseGatewayOutputs - External target creation: assignment, unassigned, duplicates, outboundAuth - Gateway target removal: listing, preview, removal operations - Preflight: gateway-only deploy validation - Credential references: cross-gateway warning on removal - Add command actions: buildGatewayTargetConfig mapping - UI: AddScreen/RemoveScreen enablement, ResourceGraph unassigned targets - Types: constants validation (AUTHORIZER_TYPE_OPTIONS, SKIP_FOR_NOW, SOURCE_OPTIONS) Adds 86 new test cases across 17 files. --- .../cloudformation/__tests__/outputs.test.ts | 114 ++++++++- .../commands/add/__tests__/actions.test.ts | 68 +++++ .../commands/add/__tests__/validate.test.ts | 113 ++++++++- src/cli/commands/add/actions.ts | 2 +- .../__tests__/pre-deploy-identity.test.ts | 238 ++++++++++++++++-- .../deploy/__tests__/preflight.test.ts | 109 +++++++- .../oauth2-credential-provider.test.ts | 236 +++++++++++++++++ .../mcp/__tests__/create-mcp.test.ts | 141 +++++++++-- .../__tests__/remove-gateway-target.test.ts | 237 +++++++++++++++++ .../remove/__tests__/remove-identity.test.ts | 121 +++++++++ .../__tests__/ResourceGraph.test.tsx | 38 +++ .../screens/add/__tests__/AddScreen.test.tsx | 17 ++ .../tui/screens/mcp/__tests__/types.test.ts | 20 ++ .../remove/__tests__/RemoveScreen.test.tsx | 48 ++++ .../__tests__/agentcore-project.test.ts | 54 ++++ .../schemas/__tests__/deployed-state.test.ts | 54 ++++ src/schema/schemas/__tests__/mcp.test.ts | 136 ++++++++++ 17 files changed, 1701 insertions(+), 45 deletions(-) create mode 100644 src/cli/commands/add/__tests__/actions.test.ts create mode 100644 src/cli/operations/identity/__tests__/oauth2-credential-provider.test.ts create mode 100644 src/cli/operations/remove/__tests__/remove-gateway-target.test.ts create mode 100644 src/cli/operations/remove/__tests__/remove-identity.test.ts create mode 100644 src/cli/tui/screens/add/__tests__/AddScreen.test.tsx create mode 100644 src/cli/tui/screens/mcp/__tests__/types.test.ts create mode 100644 src/cli/tui/screens/remove/__tests__/RemoveScreen.test.tsx diff --git a/src/cli/cloudformation/__tests__/outputs.test.ts b/src/cli/cloudformation/__tests__/outputs.test.ts index ce85fdcf..6d1091f3 100644 --- a/src/cli/cloudformation/__tests__/outputs.test.ts +++ b/src/cli/cloudformation/__tests__/outputs.test.ts @@ -1,4 +1,4 @@ -import { buildDeployedState } from '../outputs'; +import { buildDeployedState, parseGatewayOutputs } from '../outputs'; import { describe, expect, it } from 'vitest'; describe('buildDeployedState', () => { @@ -61,4 +61,116 @@ describe('buildDeployedState', () => { expect(result.targets.prod!.resources?.stackName).toBe('ProdStack'); expect(result.targets.dev!.resources?.identityKmsKeyArn).toBe('arn:aws:kms:us-east-1:123456789012:key/dev-key'); }); + + it('includes credentials in deployed state when provided', () => { + const agents = { + TestAgent: { + runtimeId: 'rt-123', + runtimeArn: 'arn:aws:bedrock:us-east-1:123456789012:agent-runtime/rt-123', + roleArn: 'arn:aws:iam::123456789012:role/TestRole', + }, + }; + + const credentials = { + 'test-cred': { + credentialProviderArn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:test-cred', + }, + }; + + const result = buildDeployedState('default', 'TestStack', agents, {}, undefined, undefined, credentials); + + expect(result.targets.default!.resources?.credentials).toEqual(credentials); + }); + + it('omits credentials field when credentials is undefined', () => { + const agents = { + TestAgent: { + runtimeId: 'rt-123', + runtimeArn: 'arn:aws:bedrock:us-east-1:123456789012:agent-runtime/rt-123', + roleArn: 'arn:aws:iam::123456789012:role/TestRole', + }, + }; + + const result = buildDeployedState('default', 'TestStack', agents, {}); + + expect(result.targets.default!.resources?.credentials).toBeUndefined(); + }); + + it('omits credentials field when credentials is empty object', () => { + const agents = { + TestAgent: { + runtimeId: 'rt-123', + runtimeArn: 'arn:aws:bedrock:us-east-1:123456789012:agent-runtime/rt-123', + roleArn: 'arn:aws:iam::123456789012:role/TestRole', + }, + }; + + const result = buildDeployedState('default', 'TestStack', agents, {}, undefined, undefined, {}); + + expect(result.targets.default!.resources?.credentials).toBeUndefined(); + }); +}); + +describe('parseGatewayOutputs', () => { + it('extracts gateway URL from outputs matching pattern', () => { + const outputs = { + GatewayMyGatewayUrlOutput3E11FAB4: 'https://api.gateway.url', + GatewayAnotherGatewayUrlOutputABC123: 'https://another.gateway.url', + UnrelatedOutput: 'some-value', + }; + + const gatewaySpecs = { + 'my-gateway': {}, + 'another-gateway': {}, + }; + + const result = parseGatewayOutputs(outputs, gatewaySpecs); + + expect(result).toEqual({ + 'my-gateway': { + gatewayId: 'my-gateway', + gatewayArn: 'https://api.gateway.url', + }, + 'another-gateway': { + gatewayId: 'another-gateway', + gatewayArn: 'https://another.gateway.url', + }, + }); + }); + + it('handles missing gateway outputs gracefully', () => { + const outputs = { + UnrelatedOutput: 'some-value', + AnotherOutput: 'another-value', + }; + + const gatewaySpecs = { + 'my-gateway': {}, + }; + + const result = parseGatewayOutputs(outputs, gatewaySpecs); + + expect(result).toEqual({}); + }); + + it('maps multiple gateways correctly', () => { + const outputs = { + GatewayFirstGatewayUrlOutput123: 'https://first.url', + GatewaySecondGatewayUrlOutput456: 'https://second.url', + GatewayThirdGatewayUrlOutput789: 'https://third.url', + }; + + const gatewaySpecs = { + 'first-gateway': {}, + 'second-gateway': {}, + 'third-gateway': {}, + }; + + const result = parseGatewayOutputs(outputs, gatewaySpecs); + + expect(Object.keys(result)).toHaveLength(3); + expect(result['first-gateway']?.gatewayArn).toBe('https://first.url'); + expect(result['second-gateway']?.gatewayArn).toBe('https://second.url'); + expect(result['third-gateway']?.gatewayArn).toBe('https://third.url'); + }); }); diff --git a/src/cli/commands/add/__tests__/actions.test.ts b/src/cli/commands/add/__tests__/actions.test.ts new file mode 100644 index 00000000..852523bc --- /dev/null +++ b/src/cli/commands/add/__tests__/actions.test.ts @@ -0,0 +1,68 @@ +import { buildGatewayTargetConfig } from '../actions.js'; +import type { ValidatedAddGatewayTargetOptions } from '../actions.js'; +import { describe, expect, it } from 'vitest'; + +describe('buildGatewayTargetConfig', () => { + it('maps name, gateway, language correctly', () => { + const options: ValidatedAddGatewayTargetOptions = { + name: 'test-tool', + language: 'Python', + gateway: 'my-gateway', + host: 'Lambda', + }; + + const config = buildGatewayTargetConfig(options); + + expect(config.name).toBe('test-tool'); + expect(config.language).toBe('Python'); + expect(config.gateway).toBe('my-gateway'); + }); + + it('sets outboundAuth when credential provided with type != NONE', () => { + const options: ValidatedAddGatewayTargetOptions = { + name: 'test-tool', + language: 'Python', + gateway: 'my-gateway', + host: 'Lambda', + outboundAuthType: 'API_KEY', + credentialName: 'my-cred', + }; + + const config = buildGatewayTargetConfig(options); + + expect(config.outboundAuth).toEqual({ + type: 'API_KEY', + credentialName: 'my-cred', + }); + }); + + it('sets endpoint for existing-endpoint source', () => { + const options: ValidatedAddGatewayTargetOptions = { + name: 'test-tool', + language: 'Python', + gateway: 'my-gateway', + host: 'Lambda', + source: 'existing-endpoint', + endpoint: 'https://api.example.com', + }; + + const config = buildGatewayTargetConfig(options); + + expect(config.source).toBe('existing-endpoint'); + expect(config.endpoint).toBe('https://api.example.com'); + }); + + it('omits outboundAuth when type is NONE', () => { + const options: ValidatedAddGatewayTargetOptions = { + name: 'test-tool', + language: 'Python', + gateway: 'my-gateway', + host: 'Lambda', + outboundAuthType: 'NONE', + }; + + const config = buildGatewayTargetConfig(options); + + expect(config.outboundAuth).toBeUndefined(); + }); +}); diff --git a/src/cli/commands/add/__tests__/validate.test.ts b/src/cli/commands/add/__tests__/validate.test.ts index f53b7187..625e22b5 100644 --- a/src/cli/commands/add/__tests__/validate.test.ts +++ b/src/cli/commands/add/__tests__/validate.test.ts @@ -12,7 +12,15 @@ import { validateAddIdentityOptions, validateAddMemoryOptions, } from '../validate.js'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const mockReadProjectSpec = vi.fn(); + +vi.mock('../../../../lib/index.js', () => ({ + ConfigIO: class { + readProjectSpec = mockReadProjectSpec; + }, +})); // Helper: valid base options for each type const validAgentOptionsByo: AddAgentOptions = { @@ -64,6 +72,8 @@ const validIdentityOptions: AddIdentityOptions = { }; describe('validate', () => { + afterEach(() => vi.clearAllMocks()); + describe('validateAddAgentOptions', () => { // AC1: All required fields validated it('returns error for missing required fields', () => { @@ -258,6 +268,107 @@ describe('validate', () => { const result = await validateAddGatewayTargetOptions({ ...validGatewayTargetOptions }); expect(result.valid).toBe(true); }); + // AC20: existing-endpoint source validation + it('passes for valid existing-endpoint with https', async () => { + const options: AddGatewayTargetOptions = { + name: 'test-tool', + source: 'existing-endpoint', + endpoint: 'https://example.com/mcp', + }; + const result = await validateAddGatewayTargetOptions(options); + expect(result.valid).toBe(true); + expect(options.language).toBe('Other'); + }); + + it('passes for valid existing-endpoint with http', async () => { + const options: AddGatewayTargetOptions = { + name: 'test-tool', + source: 'existing-endpoint', + endpoint: 'http://localhost:3000/mcp', + }; + const result = await validateAddGatewayTargetOptions(options); + expect(result.valid).toBe(true); + }); + + it('returns error for existing-endpoint without endpoint', async () => { + const options: AddGatewayTargetOptions = { + name: 'test-tool', + source: 'existing-endpoint', + }; + const result = await validateAddGatewayTargetOptions(options); + expect(result.valid).toBe(false); + expect(result.error).toBe('--endpoint is required when source is existing-endpoint'); + }); + + it('returns error for existing-endpoint with non-http(s) URL', async () => { + const options: AddGatewayTargetOptions = { + name: 'test-tool', + source: 'existing-endpoint', + endpoint: 'ftp://example.com/mcp', + }; + const result = await validateAddGatewayTargetOptions(options); + expect(result.valid).toBe(false); + expect(result.error).toBe('Endpoint must use http:// or https:// protocol'); + }); + + it('returns error for existing-endpoint with invalid URL', async () => { + const options: AddGatewayTargetOptions = { + name: 'test-tool', + source: 'existing-endpoint', + endpoint: 'not-a-url', + }; + const result = await validateAddGatewayTargetOptions(options); + expect(result.valid).toBe(false); + expect(result.error).toBe('Endpoint must be a valid URL (e.g. https://example.com/mcp)'); + }); + + // AC21: credential validation through outbound auth + it('returns error when credential not found', async () => { + mockReadProjectSpec.mockResolvedValue({ + credentials: [{ name: 'existing-cred', type: 'ApiKey' }], + }); + + const options: AddGatewayTargetOptions = { + name: 'test-tool', + language: 'Python', + outboundAuthType: 'API_KEY', + credentialName: 'missing-cred', + }; + const result = await validateAddGatewayTargetOptions(options); + expect(result.valid).toBe(false); + expect(result.error).toContain('Credential "missing-cred" not found'); + }); + + it('returns error when no credentials configured', async () => { + mockReadProjectSpec.mockResolvedValue({ + credentials: [], + }); + + const options: AddGatewayTargetOptions = { + name: 'test-tool', + language: 'Python', + outboundAuthType: 'API_KEY', + credentialName: 'any-cred', + }; + const result = await validateAddGatewayTargetOptions(options); + expect(result.valid).toBe(false); + expect(result.error).toContain('No credentials are configured'); + }); + + it('passes when credential exists', async () => { + mockReadProjectSpec.mockResolvedValue({ + credentials: [{ name: 'valid-cred', type: 'ApiKey' }], + }); + + const options: AddGatewayTargetOptions = { + name: 'test-tool', + language: 'Python', + outboundAuthType: 'API_KEY', + credentialName: 'valid-cred', + }; + const result = await validateAddGatewayTargetOptions(options); + expect(result.valid).toBe(true); + }); }); describe('validateAddMemoryOptions', () => { diff --git a/src/cli/commands/add/actions.ts b/src/cli/commands/add/actions.ts index 88c5dcc9..28b6d0cb 100644 --- a/src/cli/commands/add/actions.ts +++ b/src/cli/commands/add/actions.ts @@ -285,7 +285,7 @@ export async function handleAddGateway(options: ValidatedAddGatewayOptions): Pro } // MCP Tool handler -function buildGatewayTargetConfig(options: ValidatedAddGatewayTargetOptions): AddGatewayTargetConfig { +export function buildGatewayTargetConfig(options: ValidatedAddGatewayTargetOptions): AddGatewayTargetConfig { const sourcePath = `${APP_DIR}/${MCP_APP_SUBDIR}/${options.name}`; const description = options.description ?? `Tool for ${options.name}`; diff --git a/src/cli/operations/deploy/__tests__/pre-deploy-identity.test.ts b/src/cli/operations/deploy/__tests__/pre-deploy-identity.test.ts index 780ec8e4..cc49fe31 100644 --- a/src/cli/operations/deploy/__tests__/pre-deploy-identity.test.ts +++ b/src/cli/operations/deploy/__tests__/pre-deploy-identity.test.ts @@ -1,14 +1,30 @@ -import { setupApiKeyProviders } from '../pre-deploy-identity.js'; +import { + getAllCredentials, + hasOwnedIdentityOAuthProviders, + setupApiKeyProviders, + setupOAuth2Providers, +} from '../pre-deploy-identity.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -const { mockKmsSend, mockControlSend, mockSetTokenVaultKmsKey, mockReadEnvFile, mockGetCredentialProvider } = - vi.hoisted(() => ({ - mockKmsSend: vi.fn(), - mockControlSend: vi.fn(), - mockSetTokenVaultKmsKey: vi.fn(), - mockReadEnvFile: vi.fn(), - mockGetCredentialProvider: vi.fn(), - })); +const { + mockKmsSend, + mockControlSend, + mockSetTokenVaultKmsKey, + mockReadEnvFile, + mockGetCredentialProvider, + mockOAuth2ProviderExists, + mockCreateOAuth2Provider, + mockUpdateOAuth2Provider, +} = vi.hoisted(() => ({ + mockKmsSend: vi.fn(), + mockControlSend: vi.fn(), + mockSetTokenVaultKmsKey: vi.fn(), + mockReadEnvFile: vi.fn(), + mockGetCredentialProvider: vi.fn(), + mockOAuth2ProviderExists: vi.fn(), + mockCreateOAuth2Provider: vi.fn(), + mockUpdateOAuth2Provider: vi.fn(), +})); vi.mock('@aws-sdk/client-kms', () => ({ KMSClient: class { @@ -35,17 +51,27 @@ vi.mock('../../identity/index.js', () => ({ updateApiKeyProvider: vi.fn(), })); +vi.mock('../../identity/oauth2-credential-provider.js', () => ({ + oAuth2ProviderExists: mockOAuth2ProviderExists, + createOAuth2Provider: mockCreateOAuth2Provider, + updateOAuth2Provider: mockUpdateOAuth2Provider, +})); + vi.mock('../../identity/create-identity.js', () => ({ - computeDefaultCredentialEnvVarName: vi.fn((name: string) => `${name}_API_KEY`), + computeDefaultCredentialEnvVarName: vi.fn((name: string) => `AGENTCORE_CREDENTIAL_${name.toUpperCase()}`), })); vi.mock('../../../../lib/index.js', () => ({ SecureCredentials: class { - static fromEnvVars() { - return { - merge: () => ({}), - get: () => undefined, - }; + constructor(private envVars: Record) {} + static fromEnvVars(envVars: Record) { + return new this(envVars); + } + merge(_other: any) { + return this; + } + get(key: string) { + return this.envVars[key]; } }, readEnvFile: mockReadEnvFile, @@ -173,3 +199,185 @@ describe('setupApiKeyProviders - KMS key reuse via GetTokenVault', () => { expect(mockKmsSend).not.toHaveBeenCalled(); }); }); + +describe('hasOwnedIdentityOAuthProviders', () => { + it('returns true when OAuthCredentialProvider exists', () => { + const projectSpec = { + credentials: [ + { name: 'oauth-cred', type: 'OAuthCredentialProvider' }, + { name: 'api-cred', type: 'ApiKeyCredentialProvider' }, + ], + }; + expect(hasOwnedIdentityOAuthProviders(projectSpec as any)).toBe(true); + }); + + it('returns false when only ApiKey credentials exist', () => { + const projectSpec = { + credentials: [{ name: 'api-cred', type: 'ApiKeyCredentialProvider' }], + }; + expect(hasOwnedIdentityOAuthProviders(projectSpec as any)).toBe(false); + }); + + it('returns false when no credentials exist', () => { + const projectSpec = { credentials: [] }; + expect(hasOwnedIdentityOAuthProviders(projectSpec as any)).toBe(false); + }); +}); + +describe('getAllCredentials', () => { + it('returns API key env var for ApiKeyCredentialProvider', () => { + const projectSpec = { + credentials: [{ name: 'test-api', type: 'ApiKeyCredentialProvider' }], + }; + const result = getAllCredentials(projectSpec as any); + expect(result).toEqual([{ providerName: 'test-api', envVarName: 'AGENTCORE_CREDENTIAL_TEST-API' }]); + }); + + it('returns CLIENT_ID and CLIENT_SECRET vars for OAuthCredentialProvider', () => { + const projectSpec = { + credentials: [{ name: 'oauth-provider', type: 'OAuthCredentialProvider' }], + }; + const result = getAllCredentials(projectSpec as any); + expect(result).toEqual([ + { providerName: 'oauth-provider', envVarName: 'AGENTCORE_CREDENTIAL_OAUTH_PROVIDER_CLIENT_ID' }, + { providerName: 'oauth-provider', envVarName: 'AGENTCORE_CREDENTIAL_OAUTH_PROVIDER_CLIENT_SECRET' }, + ]); + }); + + it('handles both credential types together', () => { + const projectSpec = { + credentials: [ + { name: 'api-key', type: 'ApiKeyCredentialProvider' }, + { name: 'oauth-cred', type: 'OAuthCredentialProvider' }, + ], + }; + const result = getAllCredentials(projectSpec as any); + expect(result).toEqual([ + { providerName: 'api-key', envVarName: 'AGENTCORE_CREDENTIAL_API-KEY' }, + { providerName: 'oauth-cred', envVarName: 'AGENTCORE_CREDENTIAL_OAUTH_CRED_CLIENT_ID' }, + { providerName: 'oauth-cred', envVarName: 'AGENTCORE_CREDENTIAL_OAUTH_CRED_CLIENT_SECRET' }, + ]); + }); + + it('uppercases and replaces hyphens with underscores', () => { + const projectSpec = { + credentials: [{ name: 'my-oauth-provider', type: 'OAuthCredentialProvider' }], + }; + const result = getAllCredentials(projectSpec as any); + expect(result[0]!.envVarName).toBe('AGENTCORE_CREDENTIAL_MY_OAUTH_PROVIDER_CLIENT_ID'); + expect(result[1]!.envVarName).toBe('AGENTCORE_CREDENTIAL_MY_OAUTH_PROVIDER_CLIENT_SECRET'); + }); +}); + +describe('setupOAuth2Providers', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('creates OAuth2 provider when it does not exist', async () => { + mockReadEnvFile.mockResolvedValue({ + AGENTCORE_CREDENTIAL_TEST_OAUTH_CLIENT_ID: 'client123', + AGENTCORE_CREDENTIAL_TEST_OAUTH_CLIENT_SECRET: 'secret456', + }); + mockOAuth2ProviderExists.mockResolvedValue(false); + mockCreateOAuth2Provider.mockResolvedValue({ + success: true, + result: { credentialProviderArn: 'arn:provider', clientSecretArn: 'arn:secret', callbackUrl: 'https://callback' }, + }); + + const projectSpec = { + credentials: [ + { + name: 'test-oauth', + type: 'OAuthCredentialProvider', + vendor: 'Google', + discoveryUrl: 'https://accounts.google.com/.well-known/openid_configuration', + }, + ], + }; + + const result = await setupOAuth2Providers({ + projectSpec: projectSpec as any, + configBaseDir: '/tmp', + region: 'us-east-1', + }); + + expect(result.hasErrors).toBe(false); + expect(result.results).toHaveLength(1); + expect(result.results[0]!.status).toBe('created'); + expect(mockCreateOAuth2Provider).toHaveBeenCalledWith(expect.anything(), { + name: 'test-oauth', + vendor: 'Google', + discoveryUrl: 'https://accounts.google.com/.well-known/openid_configuration', + clientId: 'client123', + clientSecret: 'secret456', + }); + }); + + it('updates OAuth2 provider when it exists', async () => { + mockReadEnvFile.mockResolvedValue({ + AGENTCORE_CREDENTIAL_TEST_OAUTH_CLIENT_ID: 'client123', + AGENTCORE_CREDENTIAL_TEST_OAUTH_CLIENT_SECRET: 'secret456', + }); + mockOAuth2ProviderExists.mockResolvedValue(true); + mockUpdateOAuth2Provider.mockResolvedValue({ success: true, result: {} }); + + const projectSpec = { + credentials: [{ name: 'test-oauth', type: 'OAuthCredentialProvider' }], + }; + + const result = await setupOAuth2Providers({ + projectSpec: projectSpec as any, + configBaseDir: '/tmp', + region: 'us-east-1', + }); + + expect(result.hasErrors).toBe(false); + expect(result.results).toHaveLength(1); + expect(result.results[0]!.status).toBe('updated'); + expect(mockUpdateOAuth2Provider).toHaveBeenCalled(); + }); + + it('skips when env vars are missing', async () => { + mockReadEnvFile.mockResolvedValue({}); + + const projectSpec = { + credentials: [{ name: 'test-oauth', type: 'OAuthCredentialProvider' }], + }; + + const result = await setupOAuth2Providers({ + projectSpec: projectSpec as any, + configBaseDir: '/tmp', + region: 'us-east-1', + }); + + expect(result.hasErrors).toBe(false); + expect(result.results).toHaveLength(1); + expect(result.results[0]!.status).toBe('skipped'); + expect(result.results[0]!.error).toContain('Missing'); + }); + + it('returns error on failure', async () => { + mockReadEnvFile.mockResolvedValue({ + AGENTCORE_CREDENTIAL_TEST_OAUTH_CLIENT_ID: 'client123', + AGENTCORE_CREDENTIAL_TEST_OAUTH_CLIENT_SECRET: 'secret456', + }); + mockOAuth2ProviderExists.mockResolvedValue(false); + mockCreateOAuth2Provider.mockResolvedValue({ success: false, error: 'Creation failed' }); + + const projectSpec = { + credentials: [{ name: 'test-oauth', type: 'OAuthCredentialProvider' }], + }; + + const result = await setupOAuth2Providers({ + projectSpec: projectSpec as any, + configBaseDir: '/tmp', + region: 'us-east-1', + }); + + expect(result.hasErrors).toBe(true); + expect(result.results).toHaveLength(1); + expect(result.results[0]!.status).toBe('error'); + expect(result.results[0]!.error).toBe('Creation failed'); + }); +}); diff --git a/src/cli/operations/deploy/__tests__/preflight.test.ts b/src/cli/operations/deploy/__tests__/preflight.test.ts index 5c9cd839..6687147b 100644 --- a/src/cli/operations/deploy/__tests__/preflight.test.ts +++ b/src/cli/operations/deploy/__tests__/preflight.test.ts @@ -1,5 +1,110 @@ -import { formatError } from '../preflight.js'; -import { describe, expect, it } from 'vitest'; +import { formatError, validateProject } from '../preflight.js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const { mockReadProjectSpec, mockReadAWSDeploymentTargets, mockReadMcpSpec, mockReadDeployedState, mockConfigExists } = + vi.hoisted(() => ({ + mockReadProjectSpec: vi.fn(), + mockReadAWSDeploymentTargets: vi.fn(), + mockReadMcpSpec: vi.fn(), + mockReadDeployedState: vi.fn(), + mockConfigExists: vi.fn(), + })); + +const { mockValidate } = vi.hoisted(() => ({ + mockValidate: vi.fn(), +})); + +const { mockValidateAwsCredentials } = vi.hoisted(() => ({ + mockValidateAwsCredentials: vi.fn(), +})); + +const { mockRequireConfigRoot } = vi.hoisted(() => ({ + mockRequireConfigRoot: vi.fn(), +})); + +vi.mock('../../../../lib/index.js', () => ({ + ConfigIO: class { + constructor(_options?: { baseDir?: string }) { + // mock constructor + } + readProjectSpec = mockReadProjectSpec; + readAWSDeploymentTargets = mockReadAWSDeploymentTargets; + readMcpSpec = mockReadMcpSpec; + readDeployedState = mockReadDeployedState; + configExists = mockConfigExists; + }, + requireConfigRoot: mockRequireConfigRoot, +})); + +vi.mock('../../../cdk/local-cdk-project.js', () => ({ + LocalCdkProject: class { + validate = mockValidate; + }, +})); + +vi.mock('../../../aws/account.js', () => ({ + validateAwsCredentials: mockValidateAwsCredentials, +})); + +describe('validateProject', () => { + afterEach(() => vi.clearAllMocks()); + + it('allows deploy when gateways exist but no agents', async () => { + mockRequireConfigRoot.mockReturnValue('/project/agentcore'); + mockValidate.mockReturnValue(undefined); + mockReadProjectSpec.mockResolvedValue({ + name: 'test-project', + agents: [], + }); + mockReadAWSDeploymentTargets.mockResolvedValue([]); + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockResolvedValue({ + agentCoreGateways: [{ name: 'test-gateway' }], + }); + mockValidateAwsCredentials.mockResolvedValue(undefined); + + const result = await validateProject(); + + expect(result.projectSpec.name).toBe('test-project'); + expect(result.isTeardownDeploy).toBe(false); + }); + + it('blocks deploy when no agents and no gateways', async () => { + mockRequireConfigRoot.mockReturnValue('/project/agentcore'); + mockValidate.mockReturnValue(undefined); + mockReadProjectSpec.mockResolvedValue({ + name: 'test-project', + agents: [], + }); + mockReadAWSDeploymentTargets.mockResolvedValue([]); + mockReadMcpSpec.mockRejectedValue(new Error('No mcp.json')); + mockReadDeployedState.mockRejectedValue(new Error('No deployed state')); + + await expect(validateProject()).rejects.toThrow( + 'No agents or gateways defined in project. Add at least one agent with "agentcore add agent" or gateway with "agentcore add gateway" before deploying.' + ); + }); + + it('allows deploy when both agents and gateways exist', async () => { + mockRequireConfigRoot.mockReturnValue('/project/agentcore'); + mockValidate.mockReturnValue(undefined); + mockReadProjectSpec.mockResolvedValue({ + name: 'test-project', + agents: [{ name: 'test-agent' }], + }); + mockReadAWSDeploymentTargets.mockResolvedValue([]); + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockResolvedValue({ + agentCoreGateways: [{ name: 'test-gateway' }], + }); + mockValidateAwsCredentials.mockResolvedValue(undefined); + + const result = await validateProject(); + + expect(result.projectSpec.name).toBe('test-project'); + expect(result.isTeardownDeploy).toBe(false); + }); +}); describe('formatError', () => { it('formats a simple Error', () => { diff --git a/src/cli/operations/identity/__tests__/oauth2-credential-provider.test.ts b/src/cli/operations/identity/__tests__/oauth2-credential-provider.test.ts new file mode 100644 index 00000000..23523dde --- /dev/null +++ b/src/cli/operations/identity/__tests__/oauth2-credential-provider.test.ts @@ -0,0 +1,236 @@ +import { + createOAuth2Provider, + getOAuth2Provider, + oAuth2ProviderExists, + updateOAuth2Provider, +} from '../oauth2-credential-provider.js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const { mockSend, MockResourceNotFoundException } = vi.hoisted(() => ({ + mockSend: vi.fn(), + MockResourceNotFoundException: class extends Error { + constructor(message = 'not found') { + super(message); + this.name = 'ResourceNotFoundException'; + } + }, +})); + +vi.mock('@aws-sdk/client-bedrock-agentcore-control', () => ({ + BedrockAgentCoreControlClient: class { + send = mockSend; + }, + CreateOauth2CredentialProviderCommand: class { + constructor(public input: unknown) {} + }, + GetOauth2CredentialProviderCommand: class { + constructor(public input: unknown) {} + }, + UpdateOauth2CredentialProviderCommand: class { + constructor(public input: unknown) {} + }, + ResourceNotFoundException: MockResourceNotFoundException, +})); + +function makeMockClient() { + return { send: mockSend } as any; +} + +describe('oAuth2ProviderExists', () => { + afterEach(() => vi.clearAllMocks()); + + it('returns true when provider exists', async () => { + mockSend.mockResolvedValue({}); + + expect(await oAuth2ProviderExists(makeMockClient(), 'my-provider')).toBe(true); + }); + + it('returns false on ResourceNotFoundException', async () => { + mockSend.mockRejectedValue(new MockResourceNotFoundException()); + + expect(await oAuth2ProviderExists(makeMockClient(), 'my-provider')).toBe(false); + }); + + it('rethrows other errors', async () => { + mockSend.mockRejectedValue(new Error('other error')); + + await expect(oAuth2ProviderExists(makeMockClient(), 'my-provider')).rejects.toThrow('other error'); + }); +}); + +describe('createOAuth2Provider', () => { + afterEach(() => vi.clearAllMocks()); + + const mockParams = { + name: 'test-provider', + vendor: 'CustomOauth2', + discoveryUrl: 'https://example.com/.well-known/openid_configuration', + clientId: 'client123', + clientSecret: 'secret123', + }; + + it('returns success with full result', async () => { + const mockResponse = { + credentialProviderArn: 'arn:aws:bedrock:us-east-1:123456789012:credential-provider/test-provider', + clientSecretArn: { secretArn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:test-secret' }, + callbackUrl: 'https://callback.example.com', + }; + mockSend.mockResolvedValue(mockResponse); + + const result = await createOAuth2Provider(makeMockClient(), mockParams); + + expect(result).toEqual({ + success: true, + result: { + credentialProviderArn: 'arn:aws:bedrock:us-east-1:123456789012:credential-provider/test-provider', + clientSecretArn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:test-secret', + callbackUrl: 'https://callback.example.com', + }, + }); + }); + + it('falls back to update on ConflictException', async () => { + const conflictError = new Error('conflict'); + Object.defineProperty(conflictError, 'name', { value: 'ConflictException' }); + + const updateResponse = { + credentialProviderArn: 'arn:aws:bedrock:us-east-1:123456789012:credential-provider/test-provider', + }; + + mockSend.mockRejectedValueOnce(conflictError); + mockSend.mockResolvedValueOnce(updateResponse); + + const result = await createOAuth2Provider(makeMockClient(), mockParams); + + expect(result).toEqual({ + success: true, + result: { + credentialProviderArn: 'arn:aws:bedrock:us-east-1:123456789012:credential-provider/test-provider', + }, + }); + }); + + it('falls back to update on ResourceAlreadyExistsException', async () => { + const existsError = new Error('already exists'); + Object.defineProperty(existsError, 'name', { value: 'ResourceAlreadyExistsException' }); + + const updateResponse = { + credentialProviderArn: 'arn:aws:bedrock:us-east-1:123456789012:credential-provider/test-provider', + }; + + mockSend.mockRejectedValueOnce(existsError); + mockSend.mockResolvedValueOnce(updateResponse); + + const result = await createOAuth2Provider(makeMockClient(), mockParams); + + expect(result).toEqual({ + success: true, + result: { + credentialProviderArn: 'arn:aws:bedrock:us-east-1:123456789012:credential-provider/test-provider', + }, + }); + }); + + it('returns error on other exceptions', async () => { + mockSend.mockRejectedValue(new Error('unexpected error')); + + const result = await createOAuth2Provider(makeMockClient(), mockParams); + + expect(result.success).toBe(false); + expect(result.error).toBe('unexpected error'); + }); + + it('returns error when no credentialProviderArn in response', async () => { + mockSend.mockResolvedValue({}); + + const result = await createOAuth2Provider(makeMockClient(), mockParams); + + expect(result).toEqual({ + success: false, + error: 'No credential provider ARN in response', + }); + }); +}); + +describe('getOAuth2Provider', () => { + afterEach(() => vi.clearAllMocks()); + + it('returns success with result', async () => { + const mockResponse = { + credentialProviderArn: 'arn:aws:bedrock:us-east-1:123456789012:credential-provider/test-provider', + clientSecretArn: { secretArn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:test-secret' }, + callbackUrl: 'https://callback.example.com', + }; + mockSend.mockResolvedValue(mockResponse); + + const result = await getOAuth2Provider(makeMockClient(), 'test-provider'); + + expect(result).toEqual({ + success: true, + result: { + credentialProviderArn: 'arn:aws:bedrock:us-east-1:123456789012:credential-provider/test-provider', + clientSecretArn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:test-secret', + callbackUrl: 'https://callback.example.com', + }, + }); + }); + + it('returns error on failure', async () => { + mockSend.mockRejectedValue(new Error('get failed')); + + const result = await getOAuth2Provider(makeMockClient(), 'test-provider'); + + expect(result.success).toBe(false); + expect(result.error).toBe('get failed'); + }); + + it('returns error when no ARN', async () => { + mockSend.mockResolvedValue({}); + + const result = await getOAuth2Provider(makeMockClient(), 'test-provider'); + + expect(result).toEqual({ + success: false, + error: 'No credential provider ARN in response', + }); + }); +}); + +describe('updateOAuth2Provider', () => { + afterEach(() => vi.clearAllMocks()); + + const mockParams = { + name: 'test-provider', + vendor: 'CustomOauth2', + discoveryUrl: 'https://example.com/.well-known/openid_configuration', + clientId: 'client123', + clientSecret: 'secret123', + }; + + it('returns success with result', async () => { + const mockResponse = { + credentialProviderArn: 'arn:aws:bedrock:us-east-1:123456789012:credential-provider/test-provider', + clientSecretArn: { secretArn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:test-secret' }, + }; + mockSend.mockResolvedValue(mockResponse); + + const result = await updateOAuth2Provider(makeMockClient(), mockParams); + + expect(result).toEqual({ + success: true, + result: { + credentialProviderArn: 'arn:aws:bedrock:us-east-1:123456789012:credential-provider/test-provider', + clientSecretArn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:test-secret', + }, + }); + }); + + it('returns error on failure', async () => { + mockSend.mockRejectedValue(new Error('update failed')); + + const result = await updateOAuth2Provider(makeMockClient(), mockParams); + + expect(result.success).toBe(false); + expect(result.error).toBe('update failed'); + }); +}); diff --git a/src/cli/operations/mcp/__tests__/create-mcp.test.ts b/src/cli/operations/mcp/__tests__/create-mcp.test.ts index d8816eb9..a1ae39a2 100644 --- a/src/cli/operations/mcp/__tests__/create-mcp.test.ts +++ b/src/cli/operations/mcp/__tests__/create-mcp.test.ts @@ -1,42 +1,133 @@ -import { computeDefaultGatewayEnvVarName, computeDefaultMcpRuntimeEnvVarName } from '../create-mcp.js'; -import { describe, expect, it } from 'vitest'; +import { SKIP_FOR_NOW } from '../../../tui/screens/mcp/types.js'; +import type { AddGatewayTargetConfig } from '../../../tui/screens/mcp/types.js'; +import { createExternalGatewayTarget } from '../create-mcp.js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; -describe('computeDefaultGatewayEnvVarName', () => { - it('converts simple name to env var', () => { - expect(computeDefaultGatewayEnvVarName('mygateway')).toBe('AGENTCORE_GATEWAY_MYGATEWAY_URL'); - }); +const { mockReadMcpSpec, mockWriteMcpSpec, mockConfigExists, mockReadProjectSpec } = vi.hoisted(() => ({ + mockReadMcpSpec: vi.fn(), + mockWriteMcpSpec: vi.fn(), + mockConfigExists: vi.fn(), + mockReadProjectSpec: vi.fn(), +})); - it('replaces hyphens with underscores', () => { - expect(computeDefaultGatewayEnvVarName('my-gateway')).toBe('AGENTCORE_GATEWAY_MY_GATEWAY_URL'); - }); +vi.mock('../../../../lib/index.js', () => ({ + ConfigIO: class { + configExists = mockConfigExists; + readMcpSpec = mockReadMcpSpec; + writeMcpSpec = mockWriteMcpSpec; + readProjectSpec = mockReadProjectSpec; + }, +})); + +function makeExternalConfig(overrides: Partial = {}): AddGatewayTargetConfig { + return { + name: 'test-target', + description: 'Test target', + sourcePath: '/tmp/test', + language: 'Other', + source: 'existing-endpoint', + endpoint: 'https://api.example.com', + gateway: 'test-gateway', + host: 'Lambda', + toolDefinition: { name: 'test-tool', description: 'Test tool' }, + ...overrides, + } as AddGatewayTargetConfig; +} + +describe('createExternalGatewayTarget', () => { + afterEach(() => vi.clearAllMocks()); - it('uppercases the name', () => { - expect(computeDefaultGatewayEnvVarName('MyGateway')).toBe('AGENTCORE_GATEWAY_MYGATEWAY_URL'); + it('creates target with endpoint and assigns to specified gateway', async () => { + const mockMcpSpec = { + agentCoreGateways: [{ name: 'test-gateway', targets: [] }], + }; + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockResolvedValue(mockMcpSpec); + + await createExternalGatewayTarget(makeExternalConfig()); + + expect(mockWriteMcpSpec).toHaveBeenCalled(); + const written = mockWriteMcpSpec.mock.calls[0]![0]; + const gateway = written.agentCoreGateways[0]!; + expect(gateway.targets).toHaveLength(1); + expect(gateway.targets[0]!.name).toBe('test-target'); + expect(gateway.targets[0]!.endpoint).toBe('https://api.example.com'); + expect(gateway.targets[0]!.targetType).toBe('mcpServer'); }); - it('handles multiple hyphens', () => { - expect(computeDefaultGatewayEnvVarName('my-cool-gateway')).toBe('AGENTCORE_GATEWAY_MY_COOL_GATEWAY_URL'); + it('stores target in unassignedTargets when gateway is skip-for-now', async () => { + const mockMcpSpec = { agentCoreGateways: [] }; + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockResolvedValue(mockMcpSpec); + + await createExternalGatewayTarget(makeExternalConfig({ gateway: SKIP_FOR_NOW })); + + expect(mockWriteMcpSpec).toHaveBeenCalled(); + const written = mockWriteMcpSpec.mock.calls[0]![0]; + expect(written.unassignedTargets).toHaveLength(1); + expect(written.unassignedTargets[0]!.name).toBe('test-target'); + expect(written.unassignedTargets[0]!.endpoint).toBe('https://api.example.com'); }); - it('handles already uppercase name', () => { - expect(computeDefaultGatewayEnvVarName('GW')).toBe('AGENTCORE_GATEWAY_GW_URL'); + it('initializes unassignedTargets array if it does not exist in mcp spec', async () => { + const mockMcpSpec = { agentCoreGateways: [] }; + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockResolvedValue(mockMcpSpec); + + await createExternalGatewayTarget(makeExternalConfig({ gateway: SKIP_FOR_NOW })); + + const written = mockWriteMcpSpec.mock.calls[0]![0]; + expect(Array.isArray(written.unassignedTargets)).toBe(true); }); -}); -describe('computeDefaultMcpRuntimeEnvVarName', () => { - it('converts simple name to env var', () => { - expect(computeDefaultMcpRuntimeEnvVarName('myruntime')).toBe('AGENTCORE_MCPRUNTIME_MYRUNTIME_URL'); + it('throws on duplicate target name in gateway', async () => { + const mockMcpSpec = { + agentCoreGateways: [{ name: 'test-gateway', targets: [{ name: 'test-target' }] }], + }; + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockResolvedValue(mockMcpSpec); + + await expect(createExternalGatewayTarget(makeExternalConfig())).rejects.toThrow( + 'Target "test-target" already exists in gateway "test-gateway"' + ); }); - it('replaces hyphens with underscores', () => { - expect(computeDefaultMcpRuntimeEnvVarName('my-runtime')).toBe('AGENTCORE_MCPRUNTIME_MY_RUNTIME_URL'); + it('throws on duplicate target name in unassigned targets', async () => { + const mockMcpSpec = { + agentCoreGateways: [], + unassignedTargets: [{ name: 'test-target' }], + }; + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockResolvedValue(mockMcpSpec); + + await expect(createExternalGatewayTarget(makeExternalConfig({ gateway: SKIP_FOR_NOW }))).rejects.toThrow( + 'Unassigned target "test-target" already exists' + ); }); - it('uppercases the name', () => { - expect(computeDefaultMcpRuntimeEnvVarName('MyRuntime')).toBe('AGENTCORE_MCPRUNTIME_MYRUNTIME_URL'); + it('throws when gateway not found', async () => { + const mockMcpSpec = { agentCoreGateways: [] }; + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockResolvedValue(mockMcpSpec); + + await expect(createExternalGatewayTarget(makeExternalConfig({ gateway: 'nonexistent' }))).rejects.toThrow( + 'Gateway "nonexistent" not found' + ); }); - it('handles multiple hyphens', () => { - expect(computeDefaultMcpRuntimeEnvVarName('a-b-c')).toBe('AGENTCORE_MCPRUNTIME_A_B_C_URL'); + it('includes outboundAuth when configured', async () => { + const mockMcpSpec = { + agentCoreGateways: [{ name: 'test-gateway', targets: [] }], + }; + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockResolvedValue(mockMcpSpec); + + await createExternalGatewayTarget( + makeExternalConfig({ outboundAuth: { type: 'API_KEY', credentialName: 'my-cred' } }) + ); + + const written = mockWriteMcpSpec.mock.calls[0]![0]; + const target = written.agentCoreGateways[0]!.targets[0]!; + expect(target.outboundAuth).toEqual({ type: 'API_KEY', credentialName: 'my-cred' }); }); }); diff --git a/src/cli/operations/remove/__tests__/remove-gateway-target.test.ts b/src/cli/operations/remove/__tests__/remove-gateway-target.test.ts new file mode 100644 index 00000000..ef7cd476 --- /dev/null +++ b/src/cli/operations/remove/__tests__/remove-gateway-target.test.ts @@ -0,0 +1,237 @@ +import { + getRemovableGatewayTargets, + previewRemoveGatewayTarget, + removeGatewayTarget, +} from '../remove-gateway-target.js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const { mockReadMcpSpec, mockWriteMcpSpec, mockReadMcpDefs, mockWriteMcpDefs, mockConfigExists, mockGetProjectRoot } = + vi.hoisted(() => ({ + mockReadMcpSpec: vi.fn(), + mockWriteMcpSpec: vi.fn(), + mockReadMcpDefs: vi.fn(), + mockWriteMcpDefs: vi.fn(), + mockConfigExists: vi.fn(), + mockGetProjectRoot: vi.fn(), + })); + +const { mockExistsSync, mockRm } = vi.hoisted(() => ({ + mockExistsSync: vi.fn(), + mockRm: vi.fn(), +})); + +vi.mock('../../../../lib/index.js', () => ({ + ConfigIO: class { + configExists = mockConfigExists; + readMcpSpec = mockReadMcpSpec; + writeMcpSpec = mockWriteMcpSpec; + readMcpDefs = mockReadMcpDefs; + writeMcpDefs = mockWriteMcpDefs; + getProjectRoot = mockGetProjectRoot; + }, +})); + +vi.mock('fs', () => ({ + existsSync: mockExistsSync, +})); + +vi.mock('fs/promises', () => ({ + rm: mockRm, +})); + +describe('getRemovableGatewayTargets', () => { + afterEach(() => vi.clearAllMocks()); + + it('returns targets from all gateways with gateway name attached', async () => { + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockResolvedValue({ + agentCoreGateways: [ + { + name: 'gateway-1', + targets: [{ name: 'target-1' }, { name: 'target-2' }], + }, + { + name: 'gateway-2', + targets: [{ name: 'target-3' }], + }, + ], + }); + + const result = await getRemovableGatewayTargets(); + + expect(result).toEqual([ + { name: 'target-1', type: 'gateway-target', gatewayName: 'gateway-1' }, + { name: 'target-2', type: 'gateway-target', gatewayName: 'gateway-1' }, + { name: 'target-3', type: 'gateway-target', gatewayName: 'gateway-2' }, + ]); + }); + + it('returns empty array when no gateways', async () => { + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockResolvedValue({ + agentCoreGateways: [], + }); + + const result = await getRemovableGatewayTargets(); + + expect(result).toEqual([]); + }); + + it('returns empty array when gateways have no targets', async () => { + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockResolvedValue({ + agentCoreGateways: [{ name: 'gateway-1', targets: [] }], + }); + + const result = await getRemovableGatewayTargets(); + + expect(result).toEqual([]); + }); +}); + +describe('previewRemoveGatewayTarget', () => { + afterEach(() => vi.clearAllMocks()); + + it('shows files that will be deleted for scaffolded targets', async () => { + mockReadMcpSpec.mockResolvedValue({ + agentCoreGateways: [ + { + name: 'test-gateway', + targets: [ + { + name: 'test-target', + compute: { + implementation: { path: 'app/test-target' }, + }, + toolDefinitions: [{ name: 'test-tool' }], + }, + ], + }, + ], + }); + mockConfigExists.mockReturnValue(true); + mockReadMcpDefs.mockResolvedValue({ + tools: { 'test-tool': { name: 'test-tool' } }, + }); + mockGetProjectRoot.mockReturnValue('/project'); + mockExistsSync.mockReturnValue(true); + + const target = { name: 'test-target', type: 'gateway-target' as const, gatewayName: 'test-gateway' }; + const result = await previewRemoveGatewayTarget(target); + + expect(result.summary).toContain('Removing gateway target: test-target (from test-gateway)'); + expect(result.summary).toContain('Deleting directory: app/test-target'); + expect(result.summary).toContain('Removing tool definition: test-tool'); + expect(result.directoriesToDelete).toEqual(['/project/app/test-target']); + }); + + it('shows correct gateway name in preview', async () => { + mockReadMcpSpec.mockResolvedValue({ + agentCoreGateways: [ + { + name: 'my-gateway', + targets: [ + { + name: 'my-target', + toolDefinitions: [{ name: 'my-tool' }], + }, + ], + }, + ], + }); + mockConfigExists.mockReturnValue(true); + mockReadMcpDefs.mockResolvedValue({ tools: {} }); + mockGetProjectRoot.mockReturnValue('/project'); + + const target = { name: 'my-target', type: 'gateway-target' as const, gatewayName: 'my-gateway' }; + const result = await previewRemoveGatewayTarget(target); + + expect(result.summary).toContain('Removing gateway target: my-target (from my-gateway)'); + }); + + it('handles external targets with no files to delete', async () => { + mockReadMcpSpec.mockResolvedValue({ + agentCoreGateways: [ + { + name: 'test-gateway', + targets: [ + { + name: 'external-target', + endpoint: 'https://api.example.com', + toolDefinitions: [{ name: 'external-tool' }], + }, + ], + }, + ], + }); + mockConfigExists.mockReturnValue(true); + mockReadMcpDefs.mockResolvedValue({ tools: {} }); + mockGetProjectRoot.mockReturnValue('/project'); + + const target = { name: 'external-target', type: 'gateway-target' as const, gatewayName: 'test-gateway' }; + const result = await previewRemoveGatewayTarget(target); + + expect(result.summary).toContain('Removing gateway target: external-target (from test-gateway)'); + expect(result.directoriesToDelete).toEqual([]); + }); +}); + +describe('removeGatewayTarget', () => { + afterEach(() => vi.clearAllMocks()); + + it('removes target from gateway config and writes updated mcp.json', async () => { + const mockMcpSpec = { + agentCoreGateways: [ + { + name: 'test-gateway', + targets: [{ name: 'target-1' }, { name: 'target-2' }], + }, + ], + }; + mockReadMcpSpec.mockResolvedValue(mockMcpSpec); + mockConfigExists.mockReturnValue(true); + mockReadMcpDefs.mockResolvedValue({ tools: {} }); + mockGetProjectRoot.mockReturnValue('/project'); + + const target = { name: 'target-1', type: 'gateway-target' as const, gatewayName: 'test-gateway' }; + const result = await removeGatewayTarget(target); + + expect(result.ok).toBe(true); + expect(mockWriteMcpSpec).toHaveBeenCalledWith({ + agentCoreGateways: [ + { + name: 'test-gateway', + targets: [{ name: 'target-2' }], + }, + ], + }); + }); + + it('handles last target in gateway', async () => { + const mockMcpSpec = { + agentCoreGateways: [ + { + name: 'test-gateway', + targets: [{ name: 'last-target' }], + }, + ], + }; + mockReadMcpSpec.mockResolvedValue(mockMcpSpec); + mockConfigExists.mockReturnValue(true); + mockReadMcpDefs.mockResolvedValue({ tools: {} }); + mockGetProjectRoot.mockReturnValue('/project'); + + const target = { name: 'last-target', type: 'gateway-target' as const, gatewayName: 'test-gateway' }; + const result = await removeGatewayTarget(target); + + expect(result.ok).toBe(true); + expect(mockWriteMcpSpec).toHaveBeenCalledWith({ + agentCoreGateways: [ + { + name: 'test-gateway', + targets: [], + }, + ], + }); + }); +}); diff --git a/src/cli/operations/remove/__tests__/remove-identity.test.ts b/src/cli/operations/remove/__tests__/remove-identity.test.ts new file mode 100644 index 00000000..b6172a33 --- /dev/null +++ b/src/cli/operations/remove/__tests__/remove-identity.test.ts @@ -0,0 +1,121 @@ +import { previewRemoveCredential } from '../remove-identity.js'; +import { describe, expect, it, vi } from 'vitest'; + +const { mockReadProjectSpec, mockConfigExists, mockReadMcpSpec } = vi.hoisted(() => ({ + mockReadProjectSpec: vi.fn(), + mockConfigExists: vi.fn(), + mockReadMcpSpec: vi.fn(), +})); + +vi.mock('../../../../lib/index.js', () => ({ + ConfigIO: class { + readProjectSpec = mockReadProjectSpec; + configExists = mockConfigExists; + readMcpSpec = mockReadMcpSpec; + }, +})); + +describe('previewRemoveCredential', () => { + it('shows warning when credential is referenced by gateway targets outboundAuth', async () => { + mockReadProjectSpec.mockResolvedValue({ + credentials: [{ name: 'test-cred', type: 'API_KEY' }], + }); + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockResolvedValue({ + agentCoreGateways: [ + { + name: 'gateway1', + targets: [ + { + name: 'target1', + outboundAuth: { credentialName: 'test-cred' }, + }, + ], + }, + ], + }); + + const result = await previewRemoveCredential('test-cred'); + + expect(result.summary).toContain( + 'Warning: Credential "test-cred" is referenced by gateway targets: gateway1/target1. Removing it may break these targets.' + ); + }); + + it('lists which targets reference the credential', async () => { + mockReadProjectSpec.mockResolvedValue({ + credentials: [{ name: 'shared-cred', type: 'API_KEY' }], + }); + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockResolvedValue({ + agentCoreGateways: [ + { + name: 'gateway1', + targets: [ + { name: 'target1', outboundAuth: { credentialName: 'shared-cred' } }, + { name: 'target2', outboundAuth: { credentialName: 'other-cred' } }, + ], + }, + { + name: 'gateway2', + targets: [{ name: 'target3', outboundAuth: { credentialName: 'shared-cred' } }], + }, + ], + }); + + const result = await previewRemoveCredential('shared-cred'); + + expect(result.summary).toContain( + 'Warning: Credential "shared-cred" is referenced by gateway targets: gateway1/target1, gateway2/target3. Removing it may break these targets.' + ); + }); + + it('shows no warning when credential is not referenced', async () => { + mockReadProjectSpec.mockResolvedValue({ + credentials: [{ name: 'unused-cred', type: 'API_KEY' }], + }); + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockResolvedValue({ + agentCoreGateways: [ + { + name: 'gateway1', + targets: [{ name: 'target1', outboundAuth: { credentialName: 'other-cred' } }], + }, + ], + }); + + const result = await previewRemoveCredential('unused-cred'); + + const warningMessage = result.summary.find(s => s.includes('Warning')); + expect(warningMessage).toBeUndefined(); + }); + + it('checks across ALL gateways targets for references', async () => { + mockReadProjectSpec.mockResolvedValue({ + credentials: [{ name: 'test-cred', type: 'API_KEY' }], + }); + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockResolvedValue({ + agentCoreGateways: [ + { + name: 'gateway1', + targets: [{ name: 'target1' }], + }, + { + name: 'gateway2', + targets: [{ name: 'target2', outboundAuth: { credentialName: 'test-cred' } }], + }, + { + name: 'gateway3', + targets: [{ name: 'target3' }], + }, + ], + }); + + const result = await previewRemoveCredential('test-cred'); + + expect(result.summary).toContain( + 'Warning: Credential "test-cred" is referenced by gateway targets: gateway2/target2. Removing it may break these targets.' + ); + }); +}); diff --git a/src/cli/tui/components/__tests__/ResourceGraph.test.tsx b/src/cli/tui/components/__tests__/ResourceGraph.test.tsx index bbdf6883..358e1386 100644 --- a/src/cli/tui/components/__tests__/ResourceGraph.test.tsx +++ b/src/cli/tui/components/__tests__/ResourceGraph.test.tsx @@ -137,4 +137,42 @@ describe('ResourceGraph', () => { expect(lastFrame()).toContain('memory'); expect(lastFrame()).toContain('credential'); }); + + it('renders ⚠ indicator when unassigned targets exist in mcp spec', () => { + const mcp: AgentCoreMcpSpec = { + agentCoreGateways: [], + unassignedTargets: [{ name: 'unassigned-target', targetType: 'mcpServer' }], + } as unknown as AgentCoreMcpSpec; + + const { lastFrame } = render(); + + expect(lastFrame()).toContain('⚠ Unassigned Targets'); + expect(lastFrame()).toContain('⚠'); + }); + + it('shows unassigned target names', () => { + const mcp: AgentCoreMcpSpec = { + agentCoreGateways: [], + unassignedTargets: [ + { name: 'target-1', targetType: 'mcpServer' }, + { name: 'target-2', targetType: 'mcpServer' }, + ], + } as unknown as AgentCoreMcpSpec; + + const { lastFrame } = render(); + + expect(lastFrame()).toContain('target-1'); + expect(lastFrame()).toContain('target-2'); + }); + + it('does not render unassigned section when no unassigned targets', () => { + const mcp: AgentCoreMcpSpec = { + agentCoreGateways: [], + unassignedTargets: [], + } as unknown as AgentCoreMcpSpec; + + const { lastFrame } = render(); + + expect(lastFrame()).not.toContain('⚠ Unassigned Targets'); + }); }); diff --git a/src/cli/tui/screens/add/__tests__/AddScreen.test.tsx b/src/cli/tui/screens/add/__tests__/AddScreen.test.tsx new file mode 100644 index 00000000..546cdb15 --- /dev/null +++ b/src/cli/tui/screens/add/__tests__/AddScreen.test.tsx @@ -0,0 +1,17 @@ +import { AddScreen } from '../AddScreen.js'; +import { render } from 'ink-testing-library'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +describe('AddScreen', () => { + it('gateway and gateway-target options are present and not disabled', () => { + const onSelect = vi.fn(); + const onExit = vi.fn(); + + const { lastFrame } = render(); + + expect(lastFrame()).toContain('Gateway'); + expect(lastFrame()).toContain('MCP Tool'); + expect(lastFrame()).not.toContain('Add an agent first'); + }); +}); diff --git a/src/cli/tui/screens/mcp/__tests__/types.test.ts b/src/cli/tui/screens/mcp/__tests__/types.test.ts new file mode 100644 index 00000000..31c1e9db --- /dev/null +++ b/src/cli/tui/screens/mcp/__tests__/types.test.ts @@ -0,0 +1,20 @@ +import { AUTHORIZER_TYPE_OPTIONS, SKIP_FOR_NOW, SOURCE_OPTIONS } from '../types.js'; +import { describe, expect, it } from 'vitest'; + +describe('MCP types constants', () => { + it('AUTHORIZER_TYPE_OPTIONS: AWS_IAM is first option', () => { + expect(AUTHORIZER_TYPE_OPTIONS[0]?.id).toBe('AWS_IAM'); + }); + + it('SKIP_FOR_NOW equals skip-for-now', () => { + expect(SKIP_FOR_NOW).toBe('skip-for-now'); + }); + + it('SOURCE_OPTIONS has entries for existing-endpoint and create-new', () => { + const existingEndpoint = SOURCE_OPTIONS.find((opt: { id: string }) => opt.id === 'existing-endpoint'); + const createNew = SOURCE_OPTIONS.find((opt: { id: string }) => opt.id === 'create-new'); + + expect(existingEndpoint).toBeDefined(); + expect(createNew).toBeDefined(); + }); +}); diff --git a/src/cli/tui/screens/remove/__tests__/RemoveScreen.test.tsx b/src/cli/tui/screens/remove/__tests__/RemoveScreen.test.tsx new file mode 100644 index 00000000..127a86e7 --- /dev/null +++ b/src/cli/tui/screens/remove/__tests__/RemoveScreen.test.tsx @@ -0,0 +1,48 @@ +import { RemoveScreen } from '../RemoveScreen.js'; +import { render } from 'ink-testing-library'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +describe('RemoveScreen', () => { + it('gateway and gateway-target options enabled when counts > 0', () => { + const onSelect = vi.fn(); + const onExit = vi.fn(); + + const { lastFrame } = render( + + ); + + expect(lastFrame()).toContain('Gateway'); + expect(lastFrame()).toContain('MCP Tool'); + expect(lastFrame()).not.toContain('No gateways to remove'); + expect(lastFrame()).not.toContain('No gateway targets to remove'); + }); + + it('gateway and gateway-target options disabled when counts = 0', () => { + const onSelect = vi.fn(); + const onExit = vi.fn(); + + const { lastFrame } = render( + + ); + + expect(lastFrame()).toContain('No gateways to remove'); + expect(lastFrame()).toContain('No gateway targets to remove'); + }); +}); diff --git a/src/schema/schemas/__tests__/agentcore-project.test.ts b/src/schema/schemas/__tests__/agentcore-project.test.ts index 64565f66..f6beaf74 100644 --- a/src/schema/schemas/__tests__/agentcore-project.test.ts +++ b/src/schema/schemas/__tests__/agentcore-project.test.ts @@ -263,6 +263,60 @@ describe('CredentialSchema', () => { }); expect(result.success).toBe(false); }); + + it('ApiKeyCredentialProvider with name passes', () => { + const result = CredentialSchema.safeParse({ + type: 'ApiKeyCredentialProvider', + name: 'MyApiKey', + }); + expect(result.success).toBe(true); + }); + + it('OAuthCredentialProvider with name and discoveryUrl passes', () => { + const result = CredentialSchema.safeParse({ + type: 'OAuthCredentialProvider', + name: 'MyOAuth', + discoveryUrl: 'https://example.com/.well-known/openid-configuration', + }); + expect(result.success).toBe(true); + }); + + it('OAuthCredentialProvider with scopes omitted passes', () => { + const result = CredentialSchema.safeParse({ + type: 'OAuthCredentialProvider', + name: 'MyOAuth', + discoveryUrl: 'https://example.com/.well-known/openid-configuration', + }); + expect(result.success).toBe(true); + }); + + it('OAuthCredentialProvider without discoveryUrl fails', () => { + const result = CredentialSchema.safeParse({ + type: 'OAuthCredentialProvider', + name: 'MyOAuth', + }); + expect(result.success).toBe(false); + }); + + it('invalid type fails discriminated union', () => { + const result = CredentialSchema.safeParse({ + type: 'InvalidCredentialType', + name: 'MyCred', + }); + expect(result.success).toBe(false); + }); + + it('vendor defaults to CustomOauth2', () => { + const result = CredentialSchema.safeParse({ + type: 'OAuthCredentialProvider', + name: 'MyOAuth', + discoveryUrl: 'https://example.com/.well-known/openid-configuration', + }); + expect(result.success).toBe(true); + if (result.success && result.data.type === 'OAuthCredentialProvider') { + expect(result.data.vendor).toBe('CustomOauth2'); + } + }); }); describe('AgentCoreProjectSpecSchema', () => { diff --git a/src/schema/schemas/__tests__/deployed-state.test.ts b/src/schema/schemas/__tests__/deployed-state.test.ts index dd57d183..74c9f6ee 100644 --- a/src/schema/schemas/__tests__/deployed-state.test.ts +++ b/src/schema/schemas/__tests__/deployed-state.test.ts @@ -1,5 +1,6 @@ import { AgentCoreDeployedStateSchema, + CredentialDeployedStateSchema, CustomJwtAuthorizerSchema, DeployedResourceStateSchema, DeployedStateSchema, @@ -137,6 +138,47 @@ describe('VpcConfigSchema', () => { }); }); +describe('CredentialDeployedStateSchema', () => { + it('accepts valid credential state with all fields', () => { + const result = CredentialDeployedStateSchema.safeParse({ + credentialProviderArn: 'arn:aws:bedrock:us-east-1:123:credential-provider/my-cred', + clientSecretArn: 'arn:aws:secretsmanager:us-east-1:123:secret:my-secret', + callbackUrl: 'https://callback.example.com', + }); + expect(result.success).toBe(true); + }); + + it('accepts credential state with only required credentialProviderArn', () => { + const result = CredentialDeployedStateSchema.safeParse({ + credentialProviderArn: 'arn:aws:bedrock:us-east-1:123:credential-provider/my-cred', + }); + expect(result.success).toBe(true); + }); + + it('accepts credential state with optional clientSecretArn', () => { + const result = CredentialDeployedStateSchema.safeParse({ + credentialProviderArn: 'arn:aws:bedrock:us-east-1:123:credential-provider/my-cred', + clientSecretArn: 'arn:aws:secretsmanager:us-east-1:123:secret:my-secret', + }); + expect(result.success).toBe(true); + }); + + it('accepts credential state with optional callbackUrl', () => { + const result = CredentialDeployedStateSchema.safeParse({ + credentialProviderArn: 'arn:aws:bedrock:us-east-1:123:credential-provider/my-cred', + callbackUrl: 'https://callback.example.com', + }); + expect(result.success).toBe(true); + }); + + it('rejects credential state without credentialProviderArn', () => { + const result = CredentialDeployedStateSchema.safeParse({ + clientSecretArn: 'arn:aws:secretsmanager:us-east-1:123:secret:my-secret', + }); + expect(result.success).toBe(false); + }); +}); + describe('DeployedResourceStateSchema', () => { it('accepts empty resource state', () => { expect(DeployedResourceStateSchema.safeParse({}).success).toBe(true); @@ -162,6 +204,18 @@ describe('DeployedResourceStateSchema', () => { }); expect(result.success).toBe(true); }); + + it('accepts resource state with credentials', () => { + const result = DeployedResourceStateSchema.safeParse({ + credentials: { + MyCred: { + credentialProviderArn: 'arn:aws:bedrock:us-east-1:123:credential-provider/my-cred', + clientSecretArn: 'arn:aws:secretsmanager:us-east-1:123:secret:my-secret', + }, + }, + }); + expect(result.success).toBe(true); + }); }); describe('DeployedStateSchema', () => { diff --git a/src/schema/schemas/__tests__/mcp.test.ts b/src/schema/schemas/__tests__/mcp.test.ts index 2414406b..8c95c268 100644 --- a/src/schema/schemas/__tests__/mcp.test.ts +++ b/src/schema/schemas/__tests__/mcp.test.ts @@ -390,6 +390,110 @@ describe('AgentCoreMcpRuntimeToolSchema', () => { }); }); +describe('AgentCoreGatewayTargetSchema with outbound auth', () => { + const validToolDef = { + name: 'myTool', + description: 'A test tool', + inputSchema: { type: 'object' as const }, + }; + + it('outboundAuth with type OAUTH but no credentialName fails', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'myTarget', + targetType: 'lambda', + toolDefinitions: [validToolDef], + compute: { + host: 'Lambda', + implementation: { language: 'Python', path: 'tools', handler: 'h' }, + pythonVersion: 'PYTHON_3_12', + }, + outboundAuth: { type: 'OAUTH' }, + }); + expect(result.success).toBe(false); + }); + + it('outboundAuth with type NONE and no credentialName passes', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'myTarget', + targetType: 'lambda', + toolDefinitions: [validToolDef], + compute: { + host: 'Lambda', + implementation: { language: 'Python', path: 'tools', handler: 'h' }, + pythonVersion: 'PYTHON_3_12', + }, + outboundAuth: { type: 'NONE' }, + }); + expect(result.success).toBe(true); + }); + + it('outboundAuth with type OAUTH and credentialName passes', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'myTarget', + targetType: 'lambda', + toolDefinitions: [validToolDef], + compute: { + host: 'Lambda', + implementation: { language: 'Python', path: 'tools', handler: 'h' }, + pythonVersion: 'PYTHON_3_12', + }, + outboundAuth: { type: 'OAUTH', credentialName: 'my-oauth-cred' }, + }); + expect(result.success).toBe(true); + }); + + it('mcpServer target with endpoint and no compute passes', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'myTarget', + targetType: 'mcpServer', + endpoint: 'https://example.com/mcp', + }); + expect(result.success).toBe(true); + }); + + it('mcpServer target with compute and no endpoint passes', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'myTarget', + targetType: 'mcpServer', + compute: { + host: 'AgentCoreRuntime', + implementation: { language: 'Python', path: 'tools', handler: 'h' }, + }, + }); + expect(result.success).toBe(true); + }); + + it('mcpServer target with neither endpoint nor compute fails', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'myTarget', + targetType: 'mcpServer', + }); + expect(result.success).toBe(false); + }); + + it('Lambda target without compute fails', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'myTarget', + targetType: 'lambda', + toolDefinitions: [validToolDef], + }); + expect(result.success).toBe(false); + }); + + it('Lambda target without toolDefinitions fails', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'myTarget', + targetType: 'lambda', + compute: { + host: 'Lambda', + implementation: { language: 'Python', path: 'tools', handler: 'h' }, + pythonVersion: 'PYTHON_3_12', + }, + }); + expect(result.success).toBe(false); + }); +}); + describe('AgentCoreMcpSpecSchema', () => { it('accepts valid MCP spec', () => { const validToolDef = { @@ -427,4 +531,36 @@ describe('AgentCoreMcpSpecSchema', () => { }); expect(result.success).toBe(false); }); + + it('spec with unassignedTargets array parses correctly', () => { + const validToolDef = { + name: 'tool', + description: 'A tool', + inputSchema: { type: 'object' as const }, + }; + + const result = AgentCoreMcpSpecSchema.safeParse({ + agentCoreGateways: [], + unassignedTargets: [ + { + name: 'unassigned-target', + targetType: 'lambda', + toolDefinitions: [validToolDef], + compute: { + host: 'Lambda', + implementation: { language: 'Python', path: 'tools', handler: 'h' }, + pythonVersion: 'PYTHON_3_12', + }, + }, + ], + }); + expect(result.success).toBe(true); + }); + + it('spec without unassignedTargets parses correctly', () => { + const result = AgentCoreMcpSpecSchema.safeParse({ + agentCoreGateways: [], + }); + expect(result.success).toBe(true); + }); }); From 3f7cc9715f2ea6595595b10c2c55118bd7d4b46f Mon Sep 17 00:00:00 2001 From: Aidan Daly <99039782+aidandaly24@users.noreply.github.com> Date: Tue, 24 Feb 2026 13:05:00 -0500 Subject: [PATCH 08/28] feat: assign unassigned targets to gateways and preserve targets on removal (#410) * feat: assign unassigned targets to gateways and preserve targets on removal * test: add unit tests for unassigned target assignment and gateway removal - getUnassignedTargets: returns targets, empty when no config, empty when field missing - createGatewayFromWizard: moves selected targets to new gateway, removes from unassigned - removeGateway: preserves targets as unassigned on removal, no-op for empty gateways - previewRemoveGateway: shows 'will become unassigned' warning * style: fix formatting and merge duplicate imports * docs: add comment explaining unassigned targets preservation --- .../mcp/__tests__/create-mcp.test.ts | 76 +++++++++- src/cli/operations/mcp/create-mcp.ts | 37 ++++- .../remove/__tests__/remove-gateway.test.ts | 68 +++++++++ src/cli/operations/remove/remove-gateway.ts | 10 +- src/cli/tui/hooks/useCreateMcp.ts | 20 +++ src/cli/tui/screens/mcp/AddGatewayFlow.tsx | 5 +- src/cli/tui/screens/mcp/AddGatewayScreen.tsx | 60 ++++++-- src/cli/tui/screens/mcp/types.ts | 5 +- .../tui/screens/mcp/useAddGatewayWizard.ts | 38 ++++- .../tui/screens/schema/McpGuidedEditor.tsx | 135 +++++++++++++++--- 10 files changed, 416 insertions(+), 38 deletions(-) create mode 100644 src/cli/operations/remove/__tests__/remove-gateway.test.ts diff --git a/src/cli/operations/mcp/__tests__/create-mcp.test.ts b/src/cli/operations/mcp/__tests__/create-mcp.test.ts index a1ae39a2..398e8f32 100644 --- a/src/cli/operations/mcp/__tests__/create-mcp.test.ts +++ b/src/cli/operations/mcp/__tests__/create-mcp.test.ts @@ -1,6 +1,6 @@ import { SKIP_FOR_NOW } from '../../../tui/screens/mcp/types.js'; -import type { AddGatewayTargetConfig } from '../../../tui/screens/mcp/types.js'; -import { createExternalGatewayTarget } from '../create-mcp.js'; +import type { AddGatewayConfig, AddGatewayTargetConfig } from '../../../tui/screens/mcp/types.js'; +import { createExternalGatewayTarget, createGatewayFromWizard, getUnassignedTargets } from '../create-mcp.js'; import { afterEach, describe, expect, it, vi } from 'vitest'; const { mockReadMcpSpec, mockWriteMcpSpec, mockConfigExists, mockReadProjectSpec } = vi.hoisted(() => ({ @@ -131,3 +131,75 @@ describe('createExternalGatewayTarget', () => { expect(target.outboundAuth).toEqual({ type: 'API_KEY', credentialName: 'my-cred' }); }); }); + +describe('getUnassignedTargets', () => { + afterEach(() => vi.clearAllMocks()); + + it('returns unassigned targets from mcp spec', async () => { + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockResolvedValue({ + agentCoreGateways: [], + unassignedTargets: [{ name: 't1' }, { name: 't2' }], + }); + + const result = await getUnassignedTargets(); + expect(result).toHaveLength(2); + expect(result[0]!.name).toBe('t1'); + }); + + it('returns empty array when no mcp config exists', async () => { + mockConfigExists.mockReturnValue(false); + expect(await getUnassignedTargets()).toEqual([]); + }); + + it('returns empty array when unassignedTargets field is missing', async () => { + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockResolvedValue({ agentCoreGateways: [] }); + expect(await getUnassignedTargets()).toEqual([]); + }); +}); + +describe('createGatewayFromWizard with selectedTargets', () => { + afterEach(() => vi.clearAllMocks()); + + function makeGatewayConfig(overrides: Partial = {}): AddGatewayConfig { + return { + name: 'new-gateway', + authorizerType: 'AWS_IAM', + ...overrides, + } as AddGatewayConfig; + } + + it('moves selected targets to new gateway and removes from unassigned', async () => { + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockResolvedValue({ + agentCoreGateways: [], + unassignedTargets: [ + { name: 'target-a', targetType: 'mcpServer' }, + { name: 'target-b', targetType: 'mcpServer' }, + { name: 'target-c', targetType: 'mcpServer' }, + ], + }); + + await createGatewayFromWizard(makeGatewayConfig({ selectedTargets: ['target-a', 'target-c'] })); + + const written = mockWriteMcpSpec.mock.calls[0]![0]; + const gateway = written.agentCoreGateways.find((g: { name: string }) => g.name === 'new-gateway'); + expect(gateway.targets).toHaveLength(2); + expect(gateway.targets[0]!.name).toBe('target-a'); + expect(gateway.targets[1]!.name).toBe('target-c'); + expect(written.unassignedTargets).toHaveLength(1); + expect(written.unassignedTargets[0]!.name).toBe('target-b'); + }); + + it('creates gateway with empty targets when no selectedTargets', async () => { + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockResolvedValue({ agentCoreGateways: [] }); + + await createGatewayFromWizard(makeGatewayConfig()); + + const written = mockWriteMcpSpec.mock.calls[0]![0]; + const gateway = written.agentCoreGateways.find((g: { name: string }) => g.name === 'new-gateway'); + expect(gateway.targets).toHaveLength(0); + }); +}); diff --git a/src/cli/operations/mcp/create-mcp.ts b/src/cli/operations/mcp/create-mcp.ts index 4e9b7d56..d6e36a1f 100644 --- a/src/cli/operations/mcp/create-mcp.ts +++ b/src/cli/operations/mcp/create-mcp.ts @@ -80,6 +80,22 @@ function buildAuthorizerConfiguration(config: AddGatewayConfig): AgentCoreGatewa }; } +/** + * Get list of unassigned targets from MCP spec. + */ +export async function getUnassignedTargets(): Promise { + try { + const configIO = new ConfigIO(); + if (!configIO.configExists('mcp')) { + return []; + } + const mcpSpec = await configIO.readMcpSpec(); + return mcpSpec.unassignedTargets ?? []; + } catch { + return []; + } +} + /** * Get list of existing gateway names from project spec. */ @@ -160,15 +176,34 @@ export async function createGatewayFromWizard(config: AddGatewayConfig): Promise throw new Error(`Gateway "${config.name}" already exists.`); } + // Collect selected unassigned targets + const selectedTargets: AgentCoreGatewayTarget[] = []; + if (config.selectedTargets && config.selectedTargets.length > 0) { + const unassignedTargets = mcpSpec.unassignedTargets ?? []; + for (const targetName of config.selectedTargets) { + const target = unassignedTargets.find(t => t.name === targetName); + if (target) { + selectedTargets.push(target); + } + } + } + const gateway: AgentCoreGateway = { name: config.name, description: config.description, - targets: [], + targets: selectedTargets, authorizerType: config.authorizerType, authorizerConfiguration: buildAuthorizerConfiguration(config), }; mcpSpec.agentCoreGateways.push(gateway); + + // Remove selected targets from unassigned targets + if (config.selectedTargets && config.selectedTargets.length > 0) { + const selected = config.selectedTargets; + mcpSpec.unassignedTargets = (mcpSpec.unassignedTargets ?? []).filter(t => !selected.includes(t.name)); + } + await configIO.writeMcpSpec(mcpSpec); return { name: config.name }; diff --git a/src/cli/operations/remove/__tests__/remove-gateway.test.ts b/src/cli/operations/remove/__tests__/remove-gateway.test.ts new file mode 100644 index 00000000..c503a472 --- /dev/null +++ b/src/cli/operations/remove/__tests__/remove-gateway.test.ts @@ -0,0 +1,68 @@ +import { previewRemoveGateway, removeGateway } from '../remove-gateway.js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const { mockReadMcpSpec, mockWriteMcpSpec, mockConfigExists } = vi.hoisted(() => ({ + mockReadMcpSpec: vi.fn(), + mockWriteMcpSpec: vi.fn(), + mockConfigExists: vi.fn(), +})); + +vi.mock('../../../../lib/index.js', () => ({ + ConfigIO: class { + configExists = mockConfigExists; + readMcpSpec = mockReadMcpSpec; + writeMcpSpec = mockWriteMcpSpec; + }, +})); + +describe('removeGateway', () => { + afterEach(() => vi.clearAllMocks()); + + it('moves gateway targets to unassignedTargets on removal, preserving existing', async () => { + mockReadMcpSpec.mockResolvedValue({ + agentCoreGateways: [ + { name: 'gw-to-remove', targets: [{ name: 'target-1' }, { name: 'target-2' }] }, + { name: 'other-gw', targets: [] }, + ], + unassignedTargets: [{ name: 'already-unassigned' }], + }); + + const result = await removeGateway('gw-to-remove'); + + expect(result.ok).toBe(true); + const written = mockWriteMcpSpec.mock.calls[0]![0]; + expect(written.agentCoreGateways).toHaveLength(1); + expect(written.agentCoreGateways[0]!.name).toBe('other-gw'); + expect(written.unassignedTargets).toHaveLength(3); + expect(written.unassignedTargets[0]!.name).toBe('already-unassigned'); + expect(written.unassignedTargets[1]!.name).toBe('target-1'); + expect(written.unassignedTargets[2]!.name).toBe('target-2'); + }); + + it('does not modify unassignedTargets when gateway has no targets', async () => { + mockReadMcpSpec.mockResolvedValue({ + agentCoreGateways: [{ name: 'empty-gw', targets: [] }], + }); + + const result = await removeGateway('empty-gw'); + + expect(result.ok).toBe(true); + const written = mockWriteMcpSpec.mock.calls[0]![0]; + expect(written.agentCoreGateways).toHaveLength(0); + expect(written.unassignedTargets).toBeUndefined(); + }); +}); + +describe('previewRemoveGateway', () => { + afterEach(() => vi.clearAllMocks()); + + it('shows "will become unassigned" warning when gateway has targets', async () => { + mockReadMcpSpec.mockResolvedValue({ + agentCoreGateways: [{ name: 'my-gw', targets: [{ name: 't1' }, { name: 't2' }] }], + }); + + const preview = await previewRemoveGateway('my-gw'); + + expect(preview.summary.some(s => s.includes('2 target(s) will become unassigned'))).toBe(true); + }); +}); diff --git a/src/cli/operations/remove/remove-gateway.ts b/src/cli/operations/remove/remove-gateway.ts index 201f7f12..2a0a156b 100644 --- a/src/cli/operations/remove/remove-gateway.ts +++ b/src/cli/operations/remove/remove-gateway.ts @@ -34,7 +34,7 @@ export async function previewRemoveGateway(gatewayName: string): Promise 0) { - summary.push(`Note: ${gateway.targets.length} target(s) behind this gateway will become orphaned`); + summary.push(`Note: ${gateway.targets.length} target(s) will become unassigned`); } // Compute schema changes @@ -52,9 +52,17 @@ export async function previewRemoveGateway(gatewayName: string): Promise g.name === gatewayName); + const targetsToPreserve = gatewayToRemove?.targets ?? []; + return { ...mcpSpec, agentCoreGateways: mcpSpec.agentCoreGateways.filter(g => g.name !== gatewayName), + // Preserve gateway's targets as unassigned so the user doesn't lose them. + // Only add the field if there are targets to preserve or unassignedTargets already exists. + ...(targetsToPreserve.length > 0 || mcpSpec.unassignedTargets + ? { unassignedTargets: [...(mcpSpec.unassignedTargets ?? []), ...targetsToPreserve] } + : {}), }; } diff --git a/src/cli/tui/hooks/useCreateMcp.ts b/src/cli/tui/hooks/useCreateMcp.ts index 7d0971eb..2b2b4857 100644 --- a/src/cli/tui/hooks/useCreateMcp.ts +++ b/src/cli/tui/hooks/useCreateMcp.ts @@ -5,6 +5,7 @@ import { getAvailableAgents, getExistingGateways, getExistingToolNames, + getUnassignedTargets, } from '../../operations/mcp/create-mcp'; import type { AddGatewayConfig, AddGatewayTargetConfig } from '../screens/mcp/types'; import { useCallback, useEffect, useState } from 'react'; @@ -117,3 +118,22 @@ export function useExistingToolNames() { return { toolNames, refresh }; } + +export function useUnassignedTargets() { + const [targets, setTargets] = useState([]); + + useEffect(() => { + async function load() { + const result = await getUnassignedTargets(); + setTargets(result.map(t => t.name)); + } + void load(); + }, []); + + const refresh = useCallback(async () => { + const result = await getUnassignedTargets(); + setTargets(result.map(t => t.name)); + }, []); + + return { targets, refresh }; +} diff --git a/src/cli/tui/screens/mcp/AddGatewayFlow.tsx b/src/cli/tui/screens/mcp/AddGatewayFlow.tsx index 36191b84..105b4f8c 100644 --- a/src/cli/tui/screens/mcp/AddGatewayFlow.tsx +++ b/src/cli/tui/screens/mcp/AddGatewayFlow.tsx @@ -3,7 +3,7 @@ import type { SelectableItem } from '../../components'; import { HELP_TEXT } from '../../constants'; import { useListNavigation } from '../../hooks'; import { useAgents, useAttachGateway, useGateways } from '../../hooks/useAttach'; -import { useCreateGateway, useExistingGateways } from '../../hooks/useCreateMcp'; +import { useCreateGateway, useExistingGateways, useUnassignedTargets } from '../../hooks/useCreateMcp'; import { AddSuccessScreen } from '../add/AddSuccessScreen'; import { AddGatewayScreen } from './AddGatewayScreen'; import type { AddGatewayConfig } from './types'; @@ -55,6 +55,7 @@ export function AddGatewayFlow({ }: AddGatewayFlowProps) { const { createGateway, reset: resetCreate } = useCreateGateway(); const { gateways: existingGateways, refresh: refreshGateways } = useExistingGateways(); + const { targets: unassignedTargets } = useUnassignedTargets(); const [flow, setFlow] = useState({ name: 'mode-select' }); // Bind flow hooks @@ -157,6 +158,7 @@ export function AddGatewayFlow({ @@ -183,6 +185,7 @@ export function AddGatewayFlow({ setFlow({ name: 'mode-select' })} /> diff --git a/src/cli/tui/screens/mcp/AddGatewayScreen.tsx b/src/cli/tui/screens/mcp/AddGatewayScreen.tsx index 0a6bba97..1e2db8af 100644 --- a/src/cli/tui/screens/mcp/AddGatewayScreen.tsx +++ b/src/cli/tui/screens/mcp/AddGatewayScreen.tsx @@ -24,10 +24,17 @@ interface AddGatewayScreenProps { onExit: () => void; existingGateways: string[]; availableAgents: string[]; + unassignedTargets: string[]; } -export function AddGatewayScreen({ onComplete, onExit, existingGateways, availableAgents }: AddGatewayScreenProps) { - const wizard = useAddGatewayWizard(); +export function AddGatewayScreen({ + onComplete, + onExit, + existingGateways, + availableAgents, + unassignedTargets, +}: AddGatewayScreenProps) { + const wizard = useAddGatewayWizard(unassignedTargets.length); // JWT config sub-step tracking (0 = discoveryUrl, 1 = audience, 2 = clients) const [jwtSubStep, setJwtSubStep] = useState(0); @@ -39,6 +46,11 @@ export function AddGatewayScreen({ onComplete, onExit, existingGateways, availab [availableAgents] ); + const unassignedTargetItems: SelectableItem[] = useMemo( + () => unassignedTargets.map(name => ({ id: name, title: name })), + [unassignedTargets] + ); + const authorizerItems: SelectableItem[] = useMemo( () => AUTHORIZER_TYPE_OPTIONS.map(o => ({ id: o.id, title: o.title, description: o.description })), [] @@ -48,6 +60,7 @@ export function AddGatewayScreen({ onComplete, onExit, existingGateways, availab const isAuthorizerStep = wizard.step === 'authorizer'; const isJwtConfigStep = wizard.step === 'jwt-config'; const isAgentsStep = wizard.step === 'agents'; + const isIncludeTargetsStep = wizard.step === 'include-targets'; const isConfirmStep = wizard.step === 'confirm'; const authorizerNav = useListNavigation({ @@ -66,6 +79,15 @@ export function AddGatewayScreen({ onComplete, onExit, existingGateways, availab requireSelection: false, }); + const targetsNav = useMultiSelectNavigation({ + items: unassignedTargetItems, + getId: item => item.id, + onConfirm: ids => wizard.setSelectedTargets(ids), + onExit: () => wizard.goBack(), + isActive: isIncludeTargetsStep, + requireSelection: false, + }); + useListNavigation({ items: [{ id: 'confirm', title: 'Confirm' }], onSelect: () => onComplete(wizard.config), @@ -113,13 +135,14 @@ export function AddGatewayScreen({ onComplete, onExit, existingGateways, availab } }; - const helpText = isAgentsStep - ? 'Space toggle · Enter confirm · Esc back' - : isConfirmStep - ? HELP_TEXT.CONFIRM_CANCEL - : isAuthorizerStep - ? HELP_TEXT.NAVIGATE_SELECT - : HELP_TEXT.TEXT_INPUT; + const helpText = + isAgentsStep || isIncludeTargetsStep + ? 'Space toggle · Enter confirm · Esc back' + : isConfirmStep + ? HELP_TEXT.CONFIRM_CANCEL + : isAuthorizerStep + ? HELP_TEXT.NAVIGATE_SELECT + : HELP_TEXT.TEXT_INPUT; const headerContent = ; @@ -178,6 +201,18 @@ export function AddGatewayScreen({ onComplete, onExit, existingGateways, availab ))} + {isIncludeTargetsStep && + (unassignedTargetItems.length > 0 ? ( + + ) : ( + No unassigned targets available. Press Enter to continue. + ))} + {isConfirmStep && ( 0 ? wizard.config.agents.join(', ') : '(none)' }, + { + label: 'Targets', + value: + wizard.config.selectedTargets && wizard.config.selectedTargets.length > 0 + ? wizard.config.selectedTargets.join(', ') + : '(none)', + }, ]} /> )} diff --git a/src/cli/tui/screens/mcp/types.ts b/src/cli/tui/screens/mcp/types.ts index 8a5ffeaa..e6151154 100644 --- a/src/cli/tui/screens/mcp/types.ts +++ b/src/cli/tui/screens/mcp/types.ts @@ -4,7 +4,7 @@ import type { GatewayAuthorizerType, NodeRuntime, PythonRuntime, ToolDefinition // Gateway Flow Types // ───────────────────────────────────────────────────────────────────────────── -export type AddGatewayStep = 'name' | 'authorizer' | 'jwt-config' | 'agents' | 'confirm'; +export type AddGatewayStep = 'name' | 'authorizer' | 'jwt-config' | 'agents' | 'include-targets' | 'confirm'; export interface AddGatewayConfig { name: string; @@ -19,6 +19,8 @@ export interface AddGatewayConfig { allowedAudience: string[]; allowedClients: string[]; }; + /** Selected unassigned targets to include in this gateway */ + selectedTargets?: string[]; } export const GATEWAY_STEP_LABELS: Record = { @@ -26,6 +28,7 @@ export const GATEWAY_STEP_LABELS: Record = { authorizer: 'Authorizer', 'jwt-config': 'JWT Config', agents: 'Agents', + 'include-targets': 'Include Targets', confirm: 'Confirm', }; diff --git a/src/cli/tui/screens/mcp/useAddGatewayWizard.ts b/src/cli/tui/screens/mcp/useAddGatewayWizard.ts index 136c8899..7600b49c 100644 --- a/src/cli/tui/screens/mcp/useAddGatewayWizard.ts +++ b/src/cli/tui/screens/mcp/useAddGatewayWizard.ts @@ -16,20 +16,32 @@ function getDefaultConfig(): AddGatewayConfig { agents: [], authorizerType: 'NONE', jwtConfig: undefined, + selectedTargets: [], }; } -export function useAddGatewayWizard() { +export function useAddGatewayWizard(unassignedTargetsCount = 0) { const [config, setConfig] = useState(getDefaultConfig); const [step, setStep] = useState('name'); - // Dynamic steps based on authorizer type + // Dynamic steps based on authorizer type and unassigned targets const steps = useMemo(() => { + const baseSteps: AddGatewayStep[] = ['name', 'authorizer']; + if (config.authorizerType === 'CUSTOM_JWT') { - return ['name', 'authorizer', 'jwt-config', 'agents', 'confirm']; + baseSteps.push('jwt-config'); + } + + baseSteps.push('agents'); + + if (unassignedTargetsCount > 0) { + baseSteps.push('include-targets'); } - return ['name', 'authorizer', 'agents', 'confirm']; - }, [config.authorizerType]); + + baseSteps.push('confirm'); + + return baseSteps; + }, [config.authorizerType, unassignedTargetsCount]); const currentIndex = steps.indexOf(step); @@ -69,10 +81,21 @@ export function useAddGatewayWizard() { [] ); - const setAgents = useCallback((agents: string[]) => { + const setAgents = useCallback( + (agents: string[]) => { + setConfig(c => ({ + ...c, + agents, + })); + setStep(unassignedTargetsCount > 0 ? 'include-targets' : 'confirm'); + }, + [unassignedTargetsCount] + ); + + const setSelectedTargets = useCallback((selectedTargets: string[]) => { setConfig(c => ({ ...c, - agents, + selectedTargets, })); setStep('confirm'); }, []); @@ -92,6 +115,7 @@ export function useAddGatewayWizard() { setAuthorizerType, setJwtConfig, setAgents, + setSelectedTargets, reset, }; } diff --git a/src/cli/tui/screens/schema/McpGuidedEditor.tsx b/src/cli/tui/screens/schema/McpGuidedEditor.tsx index d0b0af33..30760bd0 100644 --- a/src/cli/tui/screens/schema/McpGuidedEditor.tsx +++ b/src/cli/tui/screens/schema/McpGuidedEditor.tsx @@ -90,7 +90,7 @@ function McpEditorBody(props: { onRequestAdd?: () => void; }) { const [gateways, setGateways] = useState(props.initialSpec.agentCoreGateways); - const [unassignedTargets, _setUnassignedTargets] = useState( + const [unassignedTargets, setUnassignedTargets] = useState( props.initialSpec.unassignedTargets ?? [] ); // Only gateways view mode @@ -105,6 +105,9 @@ function McpEditorBody(props: { // Target editing state const [selectedTargetIndex, setSelectedTargetIndex] = useState(0); const [editingTargetFieldId, setEditingTargetFieldId] = useState(null); + // Unassigned target assignment state + const [selectedUnassignedIndex, setSelectedUnassignedIndex] = useState(0); + const [assigningTarget, setAssigningTarget] = useState(false); // Define editable fields for the current item const currentGateway = gateways[selectedIndex]; @@ -139,6 +142,28 @@ function McpEditorBody(props: { } } + function assignTargetToGateway(targetIndex: number, gatewayIndex: number) { + const target = unassignedTargets[targetIndex]; + if (!target) return; + + // Remove from unassigned targets + const newUnassignedTargets = unassignedTargets.filter((_, idx) => idx !== targetIndex); + setUnassignedTargets(newUnassignedTargets); + + // Add to selected gateway + const newGateways = gateways.map((gateway, idx) => { + if (idx === gatewayIndex) { + return { + ...gateway, + targets: [...gateway.targets, target], + }; + } + return gateway; + }); + setGateways(newGateways); + setDirty(true); + } + useInput((input, key) => { // Handle confirm-exit screen if (screenMode === 'confirm-exit') { @@ -219,6 +244,10 @@ function McpEditorBody(props: { // List mode keys if (key.escape) { + if (assigningTarget) { + setAssigningTarget(false); + return; + } if (expandedIndex !== null) { setExpandedIndex(null); return; @@ -231,6 +260,51 @@ function McpEditorBody(props: { return; } + // Handle unassigned target assignment mode + if (assigningTarget) { + if (key.upArrow && gateways.length > 0) { + setSelectedIndex(idx => Math.max(0, idx - 1)); + return; + } + if (key.downArrow && gateways.length > 0) { + setSelectedIndex(idx => Math.min(gateways.length - 1, idx + 1)); + return; + } + if (key.return && gateways.length > 0) { + assignTargetToGateway(selectedUnassignedIndex, selectedIndex); + setAssigningTarget(false); + setSelectedUnassignedIndex(0); + return; + } + return; + } + + // Handle unassigned targets navigation (when not in assignment mode) + if (unassignedTargets.length > 0 && !assigningTarget) { + // U key to focus unassigned targets + if (input.toLowerCase() === 'u') { + setSelectedUnassignedIndex(0); + return; + } + + // When focused on unassigned targets, use left/right arrows to navigate + if (key.leftArrow && unassignedTargets.length > 0) { + setSelectedUnassignedIndex(idx => Math.max(0, idx - 1)); + return; + } + if (key.rightArrow && unassignedTargets.length > 0) { + setSelectedUnassignedIndex(idx => Math.min(unassignedTargets.length - 1, idx + 1)); + return; + } + + // Enter to start assignment when focused on unassigned target + if (key.return && selectedUnassignedIndex < unassignedTargets.length) { + setAssigningTarget(true); + setSelectedIndex(0); + return; + } + } + // A to add (works in both views) if (input.toLowerCase() === 'a' && props.onRequestAdd) { props.onRequestAdd(); @@ -612,21 +686,50 @@ function McpEditorBody(props: { - {unassignedTargets.map((target, idx) => { - const targetName = target.name ?? `Target ${idx + 1}`; - const targetType = target.targetType; - const endpoint = target.endpoint; - const displayInfo = endpoint ?? target.compute?.host ?? targetType; - return ( - - - {targetName} - - ({targetType} · {displayInfo}) - - - ); - })} + {assigningTarget && ( + + + Assign "{unassignedTargets[selectedUnassignedIndex]?.name}" to gateway: + + + )} + {assigningTarget + ? // Show gateway selection for assignment + gateways.map((gateway, idx) => ( + + {idx === selectedIndex ? '>' : ' '} + {gateway.name} + + )) + : // Show unassigned targets + unassignedTargets.map((target, idx) => { + const targetName = target.name ?? `Target ${idx + 1}`; + const targetType = target.targetType; + const endpoint = target.endpoint; + const displayInfo = endpoint ?? target.compute?.host ?? targetType; + const isSelected = idx === selectedUnassignedIndex; + return ( + + + + {isSelected ? '>' : ' '} {targetName} + + + ({targetType} · {displayInfo}) + + + ); + })} + {!assigningTarget && unassignedTargets.length > 0 && ( + + U select · ←→ navigate · Enter assign + + )} + {assigningTarget && ( + + ↑↓ select gateway · Enter confirm · Esc cancel + + )} From 3252f3217f63170651c4179d12b322d0c57e5bcb Mon Sep 17 00:00:00 2001 From: Aidan Daly <99039782+aidandaly24@users.noreply.github.com> Date: Tue, 24 Feb 2026 17:51:57 -0500 Subject: [PATCH 09/28] feat: add OAuth credential support to add identity and outbound auth CLI flags (#416) * feat: add OAuth credential support to add identity and outbound auth CLI flags Extend createCredential to support OAuth credentials alongside API keys: - CreateCredentialConfig is now a discriminated union (ApiKey vs OAuth) - OAuth writes CLIENT_ID and CLIENT_SECRET to .env.local - OAuth writes OAuthCredentialProvider config to agentcore.json Add CLI flags for non-interactive workflows: - add identity: --type oauth, --discovery-url, --client-id, --client-secret, --scopes - add gateway-target: --outbound-auth, --credential-name, --oauth-client-id, --oauth-client-secret, --oauth-discovery-url, --oauth-scopes - Inline OAuth credential creation when --oauth-* fields provided without --credential-name Adds 15 new tests covering OAuth credential creation, validation, and edge cases. * fix: use || instead of ?? for empty string handling and add discoveryUrl validation * fix: sanitize hyphens in credential env var names for POSIX compliance * test: update env var expectations for hyphen-to-underscore sanitization --- .../commands/add/__tests__/validate.test.ts | 105 ++++++++++++++++++ src/cli/commands/add/actions.ts | 50 +++++++-- src/cli/commands/add/command.tsx | 44 +++++++- src/cli/commands/add/types.ts | 9 ++ src/cli/commands/add/validate.ts | 62 ++++++++++- .../identity/__tests__/credential-ops.test.ts | 102 ++++++++++++++++- .../operations/identity/create-identity.ts | 49 ++++++-- .../tui/screens/identity/AddIdentityFlow.tsx | 6 +- 8 files changed, 394 insertions(+), 33 deletions(-) diff --git a/src/cli/commands/add/__tests__/validate.test.ts b/src/cli/commands/add/__tests__/validate.test.ts index 625e22b5..e9a7992a 100644 --- a/src/cli/commands/add/__tests__/validate.test.ts +++ b/src/cli/commands/add/__tests__/validate.test.ts @@ -369,6 +369,59 @@ describe('validate', () => { const result = await validateAddGatewayTargetOptions(options); expect(result.valid).toBe(true); }); + + // Outbound auth inline OAuth validation + it('passes for OAUTH with inline OAuth fields', async () => { + const result = await validateAddGatewayTargetOptions({ + ...validGatewayTargetOptions, + outboundAuthType: 'OAUTH', + oauthClientId: 'cid', + oauthClientSecret: 'csec', + oauthDiscoveryUrl: 'https://auth.example.com', + }); + expect(result.valid).toBe(true); + }); + + it('returns error for OAUTH without credential-name or inline fields', async () => { + const result = await validateAddGatewayTargetOptions({ + ...validGatewayTargetOptions, + outboundAuthType: 'OAUTH', + }); + expect(result.valid).toBe(false); + expect(result.error).toContain('--credential-name or inline OAuth fields'); + }); + + it('returns error for incomplete inline OAuth (missing client-secret)', async () => { + const result = await validateAddGatewayTargetOptions({ + ...validGatewayTargetOptions, + outboundAuthType: 'OAUTH', + oauthClientId: 'cid', + oauthDiscoveryUrl: 'https://auth.example.com', + }); + expect(result.valid).toBe(false); + expect(result.error).toContain('--oauth-client-secret'); + }); + + it('returns error for API_KEY with inline OAuth fields', async () => { + const result = await validateAddGatewayTargetOptions({ + ...validGatewayTargetOptions, + outboundAuthType: 'API_KEY', + oauthClientId: 'cid', + oauthClientSecret: 'csec', + oauthDiscoveryUrl: 'https://auth.example.com', + }); + expect(result.valid).toBe(false); + expect(result.error).toContain('cannot be used with API_KEY'); + }); + + it('returns error for API_KEY without credential-name', async () => { + const result = await validateAddGatewayTargetOptions({ + ...validGatewayTargetOptions, + outboundAuthType: 'API_KEY', + }); + expect(result.valid).toBe(false); + expect(result.error).toContain('--credential-name is required'); + }); }); describe('validateAddMemoryOptions', () => { @@ -465,4 +518,56 @@ describe('validate', () => { expect(validateAddIdentityOptions(validIdentityOptions)).toEqual({ valid: true }); }); }); + + describe('validateAddIdentityOptions OAuth', () => { + it('passes for valid OAuth identity', () => { + const result = validateAddIdentityOptions({ + name: 'my-oauth', + type: 'oauth', + discoveryUrl: 'https://auth.example.com/.well-known/openid-configuration', + clientId: 'client123', + clientSecret: 'secret456', + }); + expect(result.valid).toBe(true); + }); + + it('returns error for OAuth without discovery-url', () => { + const result = validateAddIdentityOptions({ + name: 'my-oauth', + type: 'oauth', + clientId: 'client123', + clientSecret: 'secret456', + }); + expect(result.valid).toBe(false); + expect(result.error).toContain('--discovery-url'); + }); + + it('returns error for OAuth without client-id', () => { + const result = validateAddIdentityOptions({ + name: 'my-oauth', + type: 'oauth', + discoveryUrl: 'https://auth.example.com', + clientSecret: 'secret456', + }); + expect(result.valid).toBe(false); + expect(result.error).toContain('--client-id'); + }); + + it('returns error for OAuth without client-secret', () => { + const result = validateAddIdentityOptions({ + name: 'my-oauth', + type: 'oauth', + discoveryUrl: 'https://auth.example.com', + clientId: 'client123', + }); + expect(result.valid).toBe(false); + expect(result.error).toContain('--client-secret'); + }); + + it('still requires api-key for default type', () => { + const result = validateAddIdentityOptions({ name: 'my-key' }); + expect(result.valid).toBe(false); + expect(result.error).toContain('--api-key'); + }); + }); }); diff --git a/src/cli/commands/add/actions.ts b/src/cli/commands/add/actions.ts index 28b6d0cb..0d52fd3f 100644 --- a/src/cli/commands/add/actions.ts +++ b/src/cli/commands/add/actions.ts @@ -74,6 +74,10 @@ export interface ValidatedAddGatewayTargetOptions { host?: 'Lambda' | 'AgentCoreRuntime'; outboundAuthType?: 'OAUTH' | 'API_KEY' | 'NONE'; credentialName?: string; + oauthClientId?: string; + oauthClientSecret?: string; + oauthDiscoveryUrl?: string; + oauthScopes?: string; } export interface ValidatedAddMemoryOptions { @@ -82,10 +86,9 @@ export interface ValidatedAddMemoryOptions { expiry?: number; } -export interface ValidatedAddIdentityOptions { - name: string; - apiKey: string; -} +export type ValidatedAddIdentityOptions = + | { type: 'api-key'; name: string; apiKey: string } + | { type: 'oauth'; name: string; discoveryUrl: string; clientId: string; clientSecret: string; scopes?: string }; // Agent handlers export async function handleAddAgent(options: ValidatedAddAgentOptions): Promise { @@ -321,6 +324,23 @@ export async function handleAddGatewayTarget( options: ValidatedAddGatewayTargetOptions ): Promise { try { + // Auto-create OAuth credential when inline fields provided + if (options.oauthClientId && options.oauthClientSecret && options.oauthDiscoveryUrl && !options.credentialName) { + const credName = `${options.name}-oauth`; + await createCredential({ + type: 'OAuthCredentialProvider', + name: credName, + discoveryUrl: options.oauthDiscoveryUrl, + clientId: options.oauthClientId, + clientSecret: options.oauthClientSecret, + scopes: options.oauthScopes + ?.split(',') + .map(s => s.trim()) + .filter(Boolean), + }); + options.credentialName = credName; + } + const config = buildGatewayTargetConfig(options); const result = await createToolFromWizard(config); return { success: true, toolName: result.toolName, sourcePath: result.projectPath }; @@ -355,10 +375,24 @@ export async function handleAddMemory(options: ValidatedAddMemoryOptions): Promi // Identity handler (v2: top-level credential resource, no owner/user) export async function handleAddIdentity(options: ValidatedAddIdentityOptions): Promise { try { - const result = await createCredential({ - name: options.name, - apiKey: options.apiKey, - }); + const result = + options.type === 'oauth' + ? await createCredential({ + type: 'OAuthCredentialProvider', + name: options.name, + discoveryUrl: options.discoveryUrl, + clientId: options.clientId, + clientSecret: options.clientSecret, + scopes: options.scopes + ?.split(',') + .map(s => s.trim()) + .filter(Boolean), + }) + : await createCredential({ + type: 'ApiKeyCredentialProvider', + name: options.name, + apiKey: options.apiKey, + }); return { success: true, credentialName: result.name }; } catch (err) { diff --git a/src/cli/commands/add/command.tsx b/src/cli/commands/add/command.tsx index cc5ae49c..df1dd80e 100644 --- a/src/cli/commands/add/command.tsx +++ b/src/cli/commands/add/command.tsx @@ -107,12 +107,25 @@ async function handleAddGatewayTargetCLI(options: AddGatewayTargetOptions): Prom process.exit(1); } + // Map CLI flag values to internal types + const outboundAuthMap: Record = { + oauth: 'OAUTH', + 'api-key': 'API_KEY', + none: 'NONE', + }; + const result = await handleAddGatewayTarget({ name: options.name!, description: options.description, language: options.language! as 'Python' | 'TypeScript', gateway: options.gateway, host: options.host, + outboundAuthType: options.outboundAuthType ? outboundAuthMap[options.outboundAuthType.toLowerCase()] : undefined, + credentialName: options.credentialName, + oauthClientId: options.oauthClientId, + oauthClientSecret: options.oauthClientSecret, + oauthDiscoveryUrl: options.oauthDiscoveryUrl, + oauthScopes: options.oauthScopes, }); if (options.json) { @@ -170,10 +183,22 @@ async function handleAddIdentityCLI(options: AddIdentityOptions): Promise process.exit(1); } - const result = await handleAddIdentity({ - name: options.name!, - apiKey: options.apiKey!, - }); + const identityType = options.type ?? 'api-key'; + const result = + identityType === 'oauth' + ? await handleAddIdentity({ + type: 'oauth', + name: options.name!, + discoveryUrl: options.discoveryUrl!, + clientId: options.clientId!, + clientSecret: options.clientSecret!, + scopes: options.scopes, + }) + : await handleAddIdentity({ + type: 'api-key', + name: options.name!, + apiKey: options.apiKey!, + }); if (options.json) { console.log(JSON.stringify(result)); @@ -266,6 +291,12 @@ export function registerAdd(program: Command) { .option('--language ', 'Language: Python or TypeScript') .option('--gateway ', 'Gateway name') .option('--host ', 'Compute host: Lambda or AgentCoreRuntime') + .option('--outbound-auth ', 'Outbound auth type: oauth, api-key, or none') + .option('--credential-name ', 'Existing credential name for outbound auth') + .option('--oauth-client-id ', 'OAuth client ID (creates credential inline)') + .option('--oauth-client-secret ', 'OAuth client secret (creates credential inline)') + .option('--oauth-discovery-url ', 'OAuth discovery URL (creates credential inline)') + .option('--oauth-scopes ', 'OAuth scopes, comma-separated') .option('--json', 'Output as JSON') .action(async options => { requireProject(); @@ -293,7 +324,12 @@ export function registerAdd(program: Command) { .command('identity') .description('Add a credential to the project') .option('--name ', 'Credential name [non-interactive]') + .option('--type ', 'Credential type: api-key (default) or oauth') .option('--api-key ', 'The API key value [non-interactive]') + .option('--discovery-url ', 'OAuth discovery URL') + .option('--client-id ', 'OAuth client ID') + .option('--client-secret ', 'OAuth client secret') + .option('--scopes ', 'OAuth scopes, comma-separated') .option('--json', 'Output as JSON [non-interactive]') .action(async options => { requireProject(); diff --git a/src/cli/commands/add/types.ts b/src/cli/commands/add/types.ts index b37785ba..f8847a7f 100644 --- a/src/cli/commands/add/types.ts +++ b/src/cli/commands/add/types.ts @@ -53,6 +53,10 @@ export interface AddGatewayTargetOptions { host?: 'Lambda' | 'AgentCoreRuntime'; outboundAuthType?: 'OAUTH' | 'API_KEY' | 'NONE'; credentialName?: string; + oauthClientId?: string; + oauthClientSecret?: string; + oauthDiscoveryUrl?: string; + oauthScopes?: string; json?: boolean; } @@ -80,7 +84,12 @@ export interface AddMemoryResult { // Identity types (v2: credential, no owner/user concept) export interface AddIdentityOptions { name?: string; + type?: 'api-key' | 'oauth'; apiKey?: string; + discoveryUrl?: string; + clientId?: string; + clientSecret?: string; + scopes?: string; json?: boolean; } diff --git a/src/cli/commands/add/validate.ts b/src/cli/commands/add/validate.ts index 0c4c0492..27af7fde 100644 --- a/src/cli/commands/add/validate.ts +++ b/src/cli/commands/add/validate.ts @@ -228,17 +228,47 @@ export async function validateAddGatewayTargetOptions(options: AddGatewayTargetO // Validate outbound auth configuration if (options.outboundAuthType && options.outboundAuthType !== 'NONE') { - if (!options.credentialName) { + const hasInlineOAuth = !!(options.oauthClientId || options.oauthClientSecret || options.oauthDiscoveryUrl); + + // Reject inline OAuth fields with API_KEY auth type + if (options.outboundAuthType === 'API_KEY' && hasInlineOAuth) { return { valid: false, - error: `--credential-name is required when outbound auth type is ${options.outboundAuthType}`, + error: 'Inline OAuth fields cannot be used with API_KEY outbound auth. Use --credential-name instead.', }; } - // Validate that the credential exists - const credentialValidation = await validateCredentialExists(options.credentialName); - if (!credentialValidation.valid) { - return credentialValidation; + if (!options.credentialName && !hasInlineOAuth) { + return { + valid: false, + error: + options.outboundAuthType === 'API_KEY' + ? '--credential-name is required when outbound auth type is API_KEY' + : `--credential-name or inline OAuth fields (--oauth-client-id, --oauth-client-secret, --oauth-discovery-url) required when outbound auth type is ${options.outboundAuthType}`, + }; + } + + // Validate inline OAuth fields are complete + if (hasInlineOAuth) { + if (!options.oauthClientId) + return { valid: false, error: '--oauth-client-id is required for inline OAuth credential creation' }; + if (!options.oauthClientSecret) + return { valid: false, error: '--oauth-client-secret is required for inline OAuth credential creation' }; + if (!options.oauthDiscoveryUrl) + return { valid: false, error: '--oauth-discovery-url is required for inline OAuth credential creation' }; + try { + new URL(options.oauthDiscoveryUrl); + } catch { + return { valid: false, error: '--oauth-discovery-url must be a valid URL' }; + } + } + + // Validate that referenced credential exists + if (options.credentialName) { + const credentialValidation = await validateCredentialExists(options.credentialName); + if (!credentialValidation.valid) { + return credentialValidation; + } } } @@ -273,6 +303,26 @@ export function validateAddIdentityOptions(options: AddIdentityOptions): Validat return { valid: false, error: '--name is required' }; } + const identityType = options.type ?? 'api-key'; + + if (identityType === 'oauth') { + if (!options.discoveryUrl) { + return { valid: false, error: '--discovery-url is required for OAuth credentials' }; + } + try { + new URL(options.discoveryUrl); + } catch { + return { valid: false, error: '--discovery-url must be a valid URL' }; + } + if (!options.clientId) { + return { valid: false, error: '--client-id is required for OAuth credentials' }; + } + if (!options.clientSecret) { + return { valid: false, error: '--client-secret is required for OAuth credentials' }; + } + return { valid: true }; + } + if (!options.apiKey) { return { valid: false, error: '--api-key is required' }; } diff --git a/src/cli/operations/identity/__tests__/credential-ops.test.ts b/src/cli/operations/identity/__tests__/credential-ops.test.ts index c2817483..a551fc2f 100644 --- a/src/cli/operations/identity/__tests__/credential-ops.test.ts +++ b/src/cli/operations/identity/__tests__/credential-ops.test.ts @@ -40,7 +40,7 @@ describe('createCredential', () => { mockWriteProjectSpec.mockResolvedValue(undefined); mockSetEnvVar.mockResolvedValue(undefined); - const result = await createCredential({ name: 'NewCred', apiKey: 'key123' }); + const result = await createCredential({ type: 'ApiKeyCredentialProvider', name: 'NewCred', apiKey: 'key123' }); expect(result.name).toBe('NewCred'); expect(result.type).toBe('ApiKeyCredentialProvider'); @@ -53,7 +53,7 @@ describe('createCredential', () => { mockReadProjectSpec.mockResolvedValue({ credentials: [existing] }); mockSetEnvVar.mockResolvedValue(undefined); - const result = await createCredential({ name: 'ExistCred', apiKey: 'newkey' }); + const result = await createCredential({ type: 'ApiKeyCredentialProvider', name: 'ExistCred', apiKey: 'newkey' }); expect(result).toBe(existing); expect(mockWriteProjectSpec).not.toHaveBeenCalled(); @@ -104,3 +104,101 @@ describe('resolveCredentialStrategy', () => { expect(result.isAgentScoped).toBe(true); }); }); + +describe('createCredential OAuth', () => { + afterEach(() => vi.clearAllMocks()); + + it('creates OAuth credential and writes to project', async () => { + const project = { credentials: [] as any[] }; + mockReadProjectSpec.mockResolvedValue(project); + mockWriteProjectSpec.mockResolvedValue(undefined); + mockSetEnvVar.mockResolvedValue(undefined); + + const result = await createCredential({ + type: 'OAuthCredentialProvider', + name: 'my-oauth', + discoveryUrl: 'https://auth.example.com/.well-known/openid-configuration', + clientId: 'client123', + clientSecret: 'secret456', + }); + + expect(result.type).toBe('OAuthCredentialProvider'); + expect(result.name).toBe('my-oauth'); + expect(mockWriteProjectSpec).toHaveBeenCalled(); + const written = mockWriteProjectSpec.mock.calls[0]![0]; + expect(written.credentials[0]).toMatchObject({ + type: 'OAuthCredentialProvider', + name: 'my-oauth', + discoveryUrl: 'https://auth.example.com/.well-known/openid-configuration', + vendor: 'CustomOauth2', + }); + }); + + it('writes CLIENT_ID and CLIENT_SECRET to env', async () => { + mockReadProjectSpec.mockResolvedValue({ credentials: [] }); + mockWriteProjectSpec.mockResolvedValue(undefined); + mockSetEnvVar.mockResolvedValue(undefined); + + await createCredential({ + type: 'OAuthCredentialProvider', + name: 'my-oauth', + discoveryUrl: 'https://example.com', + clientId: 'cid', + clientSecret: 'csec', + }); + + expect(mockSetEnvVar).toHaveBeenCalledWith('AGENTCORE_CREDENTIAL_MY_OAUTH_CLIENT_ID', 'cid'); + expect(mockSetEnvVar).toHaveBeenCalledWith('AGENTCORE_CREDENTIAL_MY_OAUTH_CLIENT_SECRET', 'csec'); + }); + + it('uppercases name in env var keys', async () => { + mockReadProjectSpec.mockResolvedValue({ credentials: [] }); + mockWriteProjectSpec.mockResolvedValue(undefined); + mockSetEnvVar.mockResolvedValue(undefined); + + await createCredential({ + type: 'OAuthCredentialProvider', + name: 'myOauth', + discoveryUrl: 'https://example.com', + clientId: 'cid', + clientSecret: 'csec', + }); + + expect(mockSetEnvVar).toHaveBeenCalledWith('AGENTCORE_CREDENTIAL_MYOAUTH_CLIENT_ID', 'cid'); + expect(mockSetEnvVar).toHaveBeenCalledWith('AGENTCORE_CREDENTIAL_MYOAUTH_CLIENT_SECRET', 'csec'); + }); + + it('throws when OAuth credential already exists', async () => { + mockReadProjectSpec.mockResolvedValue({ + credentials: [{ name: 'existing', type: 'OAuthCredentialProvider' }], + }); + + await expect( + createCredential({ + type: 'OAuthCredentialProvider', + name: 'existing', + discoveryUrl: 'https://example.com', + clientId: 'cid', + clientSecret: 'csec', + }) + ).rejects.toThrow('Credential "existing" already exists'); + }); + + it('includes scopes when provided', async () => { + mockReadProjectSpec.mockResolvedValue({ credentials: [] }); + mockWriteProjectSpec.mockResolvedValue(undefined); + mockSetEnvVar.mockResolvedValue(undefined); + + await createCredential({ + type: 'OAuthCredentialProvider', + name: 'scoped', + discoveryUrl: 'https://example.com', + clientId: 'cid', + clientSecret: 'csec', + scopes: ['read', 'write'], + }); + + const written = mockWriteProjectSpec.mock.calls[0]![0]; + expect(written.credentials[0].scopes).toEqual(['read', 'write']); + }); +}); diff --git a/src/cli/operations/identity/create-identity.ts b/src/cli/operations/identity/create-identity.ts index 0277df94..6c6705bb 100644 --- a/src/cli/operations/identity/create-identity.ts +++ b/src/cli/operations/identity/create-identity.ts @@ -4,10 +4,17 @@ import type { Credential, ModelProvider } from '../../../schema'; /** * Config for creating a credential resource. */ -export interface CreateCredentialConfig { - name: string; - apiKey: string; -} +export type CreateCredentialConfig = + | { type: 'ApiKeyCredentialProvider'; name: string; apiKey: string } + | { + type: 'OAuthCredentialProvider'; + name: string; + discoveryUrl: string; + clientId: string; + clientSecret: string; + scopes?: string[]; + vendor?: string; + }; /** * Result of resolving credential strategy for an agent. @@ -27,7 +34,7 @@ export interface CredentialStrategy { * Compute the default env var name for a credential. */ export function computeDefaultCredentialEnvVarName(credentialName: string): string { - return `AGENTCORE_CREDENTIAL_${credentialName.toUpperCase()}`; + return `AGENTCORE_CREDENTIAL_${credentialName.toUpperCase().replace(/-/g, '_')}`; } /** @@ -103,10 +110,7 @@ export async function getAllCredentialNames(): Promise { /** * Create a credential resource and add it to the project. - * Also writes the API key to the .env file. - * - * If the credential already exists (e.g., created during agent generation), - * just updates the API key in the .env file. + * Writes the credential config to agentcore.json and secrets to .env.local. */ export async function createCredential(config: CreateCredentialConfig): Promise { const configIO = new ConfigIO(); @@ -115,12 +119,34 @@ export async function createCredential(config: CreateCredentialConfig): Promise< // Check if credential already exists const existingCredential = project.credentials.find(c => c.name === config.name); + if (config.type === 'OAuthCredentialProvider') { + if (existingCredential) { + throw new Error(`Credential "${config.name}" already exists`); + } + + const credential: Credential = { + type: 'OAuthCredentialProvider', + name: config.name, + discoveryUrl: config.discoveryUrl, + vendor: config.vendor ?? 'CustomOauth2', + ...(config.scopes && config.scopes.length > 0 ? { scopes: config.scopes } : {}), + }; + project.credentials.push(credential); + await configIO.writeProjectSpec(project); + + // Write client ID and secret to .env.local + const envBase = computeDefaultCredentialEnvVarName(config.name); + await setEnvVar(`${envBase}_CLIENT_ID`, config.clientId); + await setEnvVar(`${envBase}_CLIENT_SECRET`, config.clientSecret); + + return credential; + } + + // ApiKeyCredentialProvider let credential: Credential; if (existingCredential) { - // updates credentital credential = existingCredential; } else { - // Create new credential entry credential = { type: 'ApiKeyCredentialProvider', name: config.name, @@ -129,7 +155,6 @@ export async function createCredential(config: CreateCredentialConfig): Promise< await configIO.writeProjectSpec(project); } - // Write API key to .env file const envVarName = computeDefaultCredentialEnvVarName(config.name); await setEnvVar(envVarName, config.apiKey); diff --git a/src/cli/tui/screens/identity/AddIdentityFlow.tsx b/src/cli/tui/screens/identity/AddIdentityFlow.tsx index 23061e5f..093331dc 100644 --- a/src/cli/tui/screens/identity/AddIdentityFlow.tsx +++ b/src/cli/tui/screens/identity/AddIdentityFlow.tsx @@ -35,7 +35,11 @@ export function AddIdentityFlow({ isInteractive = true, onExit, onBack, onDev, o const handleCreateComplete = useCallback( (config: AddIdentityConfig) => { - void createIdentity(config).then(result => { + void createIdentity({ + type: 'ApiKeyCredentialProvider', + name: config.name, + apiKey: config.apiKey, + }).then(result => { if (result.ok) { setFlow({ name: 'create-success', identityName: result.result.name }); return; From 75843cf8f1946a25a2de592233a827b7f7010da0 Mon Sep 17 00:00:00 2001 From: Aidan Daly <99039782+aidandaly24@users.noreply.github.com> Date: Tue, 24 Feb 2026 17:59:08 -0500 Subject: [PATCH 10/28] fix: use nullish coalescing operator in validate.ts (#429) Fix ESLint prefer-nullish-coalescing errors. --- src/cli/commands/add/validate.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/commands/add/validate.ts b/src/cli/commands/add/validate.ts index 27af7fde..d7cbc802 100644 --- a/src/cli/commands/add/validate.ts +++ b/src/cli/commands/add/validate.ts @@ -228,7 +228,7 @@ export async function validateAddGatewayTargetOptions(options: AddGatewayTargetO // Validate outbound auth configuration if (options.outboundAuthType && options.outboundAuthType !== 'NONE') { - const hasInlineOAuth = !!(options.oauthClientId || options.oauthClientSecret || options.oauthDiscoveryUrl); + const hasInlineOAuth = !!(options.oauthClientId ?? options.oauthClientSecret ?? options.oauthDiscoveryUrl); // Reject inline OAuth fields with API_KEY auth type if (options.outboundAuthType === 'API_KEY' && hasInlineOAuth) { From ff6f8e4969047247f70e3d11d61b90b406090f2f Mon Sep 17 00:00:00 2001 From: Aidan Daly <99039782+aidandaly24@users.noreply.github.com> Date: Tue, 24 Feb 2026 18:10:12 -0500 Subject: [PATCH 11/28] feat: add OAuth credential setup and gateway output parsing to TUI deploy flow (#411) * feat: add OAuth credential setup and gateway output parsing to TUI deploy flow * refactor: rename hasOwned* to has* identity provider helpers * fix: log gateway config errors instead of silently catching --- src/cli/commands/deploy/actions.ts | 8 +-- .../__tests__/pre-deploy-identity.test.ts | 10 +-- src/cli/operations/deploy/index.ts | 4 +- .../operations/deploy/pre-deploy-identity.ts | 4 +- src/cli/tui/hooks/useCdkPreflight.ts | 68 ++++++++++++++++++- src/cli/tui/screens/deploy/useDeployFlow.ts | 28 ++++++-- 6 files changed, 103 insertions(+), 19 deletions(-) diff --git a/src/cli/commands/deploy/actions.ts b/src/cli/commands/deploy/actions.ts index b9cfc538..bfa09a1f 100644 --- a/src/cli/commands/deploy/actions.ts +++ b/src/cli/commands/deploy/actions.ts @@ -10,8 +10,8 @@ import { checkBootstrapNeeded, checkStackDeployability, getAllCredentials, - hasOwnedIdentityApiProviders, - hasOwnedIdentityOAuthProviders, + hasIdentityApiProviders, + hasIdentityOAuthProviders, performStackTeardown, setupApiKeyProviders, setupOAuth2Providers, @@ -181,7 +181,7 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise 0 ? new SecureCredentials(envCredentials) : undefined; - if (hasOwnedIdentityApiProviders(context.projectSpec)) { + if (hasIdentityApiProviders(context.projectSpec)) { startStep('Creating credentials...'); const identityResult = await setupApiKeyProviders({ @@ -208,7 +208,7 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise = {}; - if (hasOwnedIdentityOAuthProviders(context.projectSpec)) { + if (hasIdentityOAuthProviders(context.projectSpec)) { startStep('Creating OAuth credentials...'); const oauthResult = await setupOAuth2Providers({ diff --git a/src/cli/operations/deploy/__tests__/pre-deploy-identity.test.ts b/src/cli/operations/deploy/__tests__/pre-deploy-identity.test.ts index cc49fe31..f0511319 100644 --- a/src/cli/operations/deploy/__tests__/pre-deploy-identity.test.ts +++ b/src/cli/operations/deploy/__tests__/pre-deploy-identity.test.ts @@ -1,6 +1,6 @@ import { getAllCredentials, - hasOwnedIdentityOAuthProviders, + hasIdentityOAuthProviders, setupApiKeyProviders, setupOAuth2Providers, } from '../pre-deploy-identity.js'; @@ -200,7 +200,7 @@ describe('setupApiKeyProviders - KMS key reuse via GetTokenVault', () => { }); }); -describe('hasOwnedIdentityOAuthProviders', () => { +describe('hasIdentityOAuthProviders', () => { it('returns true when OAuthCredentialProvider exists', () => { const projectSpec = { credentials: [ @@ -208,19 +208,19 @@ describe('hasOwnedIdentityOAuthProviders', () => { { name: 'api-cred', type: 'ApiKeyCredentialProvider' }, ], }; - expect(hasOwnedIdentityOAuthProviders(projectSpec as any)).toBe(true); + expect(hasIdentityOAuthProviders(projectSpec as any)).toBe(true); }); it('returns false when only ApiKey credentials exist', () => { const projectSpec = { credentials: [{ name: 'api-cred', type: 'ApiKeyCredentialProvider' }], }; - expect(hasOwnedIdentityOAuthProviders(projectSpec as any)).toBe(false); + expect(hasIdentityOAuthProviders(projectSpec as any)).toBe(false); }); it('returns false when no credentials exist', () => { const projectSpec = { credentials: [] }; - expect(hasOwnedIdentityOAuthProviders(projectSpec as any)).toBe(false); + expect(hasIdentityOAuthProviders(projectSpec as any)).toBe(false); }); }); diff --git a/src/cli/operations/deploy/index.ts b/src/cli/operations/deploy/index.ts index 68c1ed35..f5de068e 100644 --- a/src/cli/operations/deploy/index.ts +++ b/src/cli/operations/deploy/index.ts @@ -17,8 +17,8 @@ export { export { setupApiKeyProviders, setupOAuth2Providers, - hasOwnedIdentityApiProviders, - hasOwnedIdentityOAuthProviders, + hasIdentityApiProviders, + hasIdentityOAuthProviders, getMissingCredentials, getAllCredentials, type SetupApiKeyProvidersOptions, diff --git a/src/cli/operations/deploy/pre-deploy-identity.ts b/src/cli/operations/deploy/pre-deploy-identity.ts index 90a6bf16..94a9e347 100644 --- a/src/cli/operations/deploy/pre-deploy-identity.ts +++ b/src/cli/operations/deploy/pre-deploy-identity.ts @@ -193,7 +193,7 @@ async function setupApiKeyCredentialProvider( /** * Check if the project has any API key credentials that need setup. */ -export function hasOwnedIdentityApiProviders(projectSpec: AgentCoreProjectSpec): boolean { +export function hasIdentityApiProviders(projectSpec: AgentCoreProjectSpec): boolean { return projectSpec.credentials.some(c => c.type === 'ApiKeyCredentialProvider'); } @@ -307,7 +307,7 @@ export async function setupOAuth2Providers(options: SetupOAuth2ProvidersOptions) /** * Check if the project has any OAuth credentials that need setup. */ -export function hasOwnedIdentityOAuthProviders(projectSpec: AgentCoreProjectSpec): boolean { +export function hasIdentityOAuthProviders(projectSpec: AgentCoreProjectSpec): boolean { return projectSpec.credentials.some(c => c.type === 'OAuthCredentialProvider'); } diff --git a/src/cli/tui/hooks/useCdkPreflight.ts b/src/cli/tui/hooks/useCdkPreflight.ts index 70ab6b12..785e60c9 100644 --- a/src/cli/tui/hooks/useCdkPreflight.ts +++ b/src/cli/tui/hooks/useCdkPreflight.ts @@ -13,8 +13,10 @@ import { checkStackDeployability, formatError, getAllCredentials, - hasOwnedIdentityApiProviders, + hasIdentityApiProviders, + hasIdentityOAuthProviders, setupApiKeyProviders, + setupOAuth2Providers, synthesizeCdk, validateProject, } from '../../operations/deploy'; @@ -65,6 +67,8 @@ export interface PreflightResult { missingCredentials: MissingCredential[]; /** KMS key ARN used for identity token vault encryption */ identityKmsKeyArn?: string; + /** OAuth credential ARNs from pre-deploy setup */ + oauthCredentials: Record; startPreflight: () => Promise; confirmTeardown: () => void; cancelTeardown: () => void; @@ -119,6 +123,9 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { const [runtimeCredentials, setRuntimeCredentials] = useState(null); const [skipIdentitySetup, setSkipIdentitySetup] = useState(false); const [identityKmsKeyArn, setIdentityKmsKeyArn] = useState(undefined); + const [oauthCredentials, setOauthCredentials] = useState< + Record + >({}); const [teardownConfirmed, setTeardownConfirmed] = useState(false); // Guard against concurrent runs (React StrictMode, re-renders, etc.) @@ -417,7 +424,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { // Check if API key providers need setup - always prompt user for credential source // Skip this check if skipIdentityCheck is true (e.g., plan command only synthesizes) - const needsApiKeySetup = !skipIdentityCheck && hasOwnedIdentityApiProviders(preflightContext.projectSpec); + const needsApiKeySetup = !skipIdentityCheck && hasIdentityApiProviders(preflightContext.projectSpec); if (needsApiKeySetup) { // Get all credentials for the prompt (not just missing ones) const allCredentials = getAllCredentials(preflightContext.projectSpec); @@ -559,6 +566,62 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { logger.endStep('success'); setSteps(prev => prev.map((s, i) => (i === prev.length - 1 ? { ...s, status: 'success' } : s))); + // Set up OAuth credential providers if needed + if (hasIdentityOAuthProviders(context.projectSpec)) { + setSteps(prev => [...prev, { label: 'Set up OAuth providers', status: 'running' }]); + logger.startStep('Set up OAuth providers'); + + const oauthResult = await setupOAuth2Providers({ + projectSpec: context.projectSpec, + configBaseDir, + region: target.region, + runtimeCredentials: runtimeCredentials ?? undefined, + }); + + for (const result of oauthResult.results) { + if (result.status === 'created') { + logger.log(`Created OAuth provider: ${result.providerName}`); + } else if (result.status === 'updated') { + logger.log(`Updated OAuth provider: ${result.providerName}`); + } else if (result.status === 'skipped') { + logger.log(`Skipped ${result.providerName}: ${result.error}`); + } else if (result.status === 'error') { + logger.log(`Error for ${result.providerName}: ${result.error}`); + } + } + + if (oauthResult.hasErrors) { + logger.endStep('error', 'Some OAuth providers failed to set up'); + setSteps(prev => + prev.map((s, i) => + i === prev.length - 1 ? { ...s, status: 'error', error: 'Some OAuth providers failed' } : s + ) + ); + setPhase('error'); + isRunningRef.current = false; + return; + } + + // Collect credential ARNs for deployed state + const creds: Record< + string, + { credentialProviderArn: string; clientSecretArn?: string; callbackUrl?: string } + > = {}; + for (const result of oauthResult.results) { + if (result.credentialProviderArn) { + creds[result.providerName] = { + credentialProviderArn: result.credentialProviderArn, + clientSecretArn: result.clientSecretArn, + callbackUrl: result.callbackUrl, + }; + } + } + setOauthCredentials(creds); + + logger.endStep('success'); + setSteps(prev => prev.map((s, i) => (i === prev.length - 1 ? { ...s, status: 'success' } : s))); + } + // Clear runtime credentials setRuntimeCredentials(null); @@ -643,6 +706,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { hasCredentialsError, missingCredentials, identityKmsKeyArn, + oauthCredentials, startPreflight, confirmTeardown, cancelTeardown, diff --git a/src/cli/tui/screens/deploy/useDeployFlow.ts b/src/cli/tui/screens/deploy/useDeployFlow.ts index eaf9b16b..e0783463 100644 --- a/src/cli/tui/screens/deploy/useDeployFlow.ts +++ b/src/cli/tui/screens/deploy/useDeployFlow.ts @@ -1,6 +1,6 @@ import { ConfigIO } from '../../../../lib'; import type { CdkToolkitWrapper, DeployMessage, SwitchableIoHost } from '../../../cdk/toolkit-lib'; -import { buildDeployedState, getStackOutputs, parseAgentOutputs } from '../../../cloudformation'; +import { buildDeployedState, getStackOutputs, parseAgentOutputs, parseGatewayOutputs } from '../../../cloudformation'; import { getErrorMessage, isChangesetInProgressError, isExpiredTokenError } from '../../../errors'; import { ExecLogger } from '../../../logging'; import { performStackTeardown } from '../../../operations/deploy'; @@ -28,6 +28,7 @@ export interface PreSynthesized { stackNames: string[]; switchableIoHost?: SwitchableIoHost; identityKmsKeyArn?: string; + oauthCredentials?: Record; } interface DeployFlowOptions { @@ -88,6 +89,7 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState const stackNames = preSynthesized?.stackNames ?? preflight.stackNames; const switchableIoHost = preSynthesized?.switchableIoHost ?? preflight.switchableIoHost; const identityKmsKeyArn = preSynthesized?.identityKmsKeyArn ?? preflight.identityKmsKeyArn; + const oauthCredentials = preSynthesized?.oauthCredentials ?? preflight.oauthCredentials; const [publishAssetsStep, setPublishAssetsStep] = useState({ label: 'Publish assets', status: 'pending' }); const [deployStep, setDeployStep] = useState({ label: 'Deploy to AWS', status: 'pending' }); @@ -163,6 +165,23 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState ); } + // Parse gateway outputs from CDK stack + let gateways: Record = {}; + try { + const mcpSpec = await configIO.readMcpSpec(); + const gatewaySpecs = + mcpSpec?.agentCoreGateways?.reduce( + (acc: Record, gateway: { name: string }) => { + acc[gateway.name] = gateway; + return acc; + }, + {} as Record + ) ?? {}; + gateways = parseGatewayOutputs(outputs, gatewaySpecs); + } catch (error) { + logger.log(`Failed to read gateway configuration: ${getErrorMessage(error)}`, 'warn'); + } + // Expose outputs to UI setStackOutputs(outputs); @@ -171,12 +190,13 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState target.name, currentStackName, agents, - {}, + gateways, existingState, - identityKmsKeyArn + identityKmsKeyArn, + Object.keys(oauthCredentials).length > 0 ? oauthCredentials : undefined ); await configIO.writeDeployedState(deployedState); - }, [context, stackNames, logger, identityKmsKeyArn]); + }, [context, stackNames, logger, identityKmsKeyArn, oauthCredentials]); // Start deploy when preflight completes OR when shouldStartDeploy is set useEffect(() => { From fff94a67e1701bb25f9636e78e11b684700ee3a8 Mon Sep 17 00:00:00 2001 From: Aidan Daly <99039782+aidandaly24@users.noreply.github.com> Date: Wed, 25 Feb 2026 10:05:32 -0500 Subject: [PATCH 12/28] feat: display gateway target sync status after deploy (#419) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Query ListGatewayTargets after successful deployment and display sync status for each target: - READY: ✓ synced - SYNCHRONIZING: ⟳ syncing... - FAILED: ✗ failed Integrated into both CLI and TUI deploy paths. TUI uses React state for proper rendering. API errors are non-blocking — deploy succeeds regardless of status query result. --- src/cli/commands/deploy/actions.ts | 11 ++- .../deploy/__tests__/gateway-status.test.ts | 83 +++++++++++++++++++ src/cli/operations/deploy/gateway-status.ts | 42 ++++++++++ src/cli/tui/screens/deploy/DeployScreen.tsx | 14 ++++ src/cli/tui/screens/deploy/useDeployFlow.ts | 14 ++++ 5 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 src/cli/operations/deploy/__tests__/gateway-status.test.ts create mode 100644 src/cli/operations/deploy/gateway-status.ts diff --git a/src/cli/commands/deploy/actions.ts b/src/cli/commands/deploy/actions.ts index bfa09a1f..bc6b14ce 100644 --- a/src/cli/commands/deploy/actions.ts +++ b/src/cli/commands/deploy/actions.ts @@ -18,6 +18,7 @@ import { synthesizeCdk, validateProject, } from '../../operations/deploy'; +import { formatTargetStatus, getGatewayTargetStatuses } from '../../operations/deploy/gateway-status'; import type { DeployResult } from './types'; export interface ValidatedDeployOptions { @@ -316,12 +317,20 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise 0) { const gatewayUrls = Object.entries(gateways) .map(([name, gateway]) => `${name}: ${gateway.gatewayArn}`) .join(', '); logger.log(`Gateway URLs: ${gatewayUrls}`); + + // Query target sync statuses (non-blocking) + for (const [, gateway] of Object.entries(gateways)) { + const statuses = await getGatewayTargetStatuses(gateway.gatewayId, target.region); + for (const targetStatus of statuses) { + logger.log(` ${targetStatus.name}: ${formatTargetStatus(targetStatus.status)}`); + } + } } endStep('success'); diff --git a/src/cli/operations/deploy/__tests__/gateway-status.test.ts b/src/cli/operations/deploy/__tests__/gateway-status.test.ts new file mode 100644 index 00000000..9efd30a6 --- /dev/null +++ b/src/cli/operations/deploy/__tests__/gateway-status.test.ts @@ -0,0 +1,83 @@ +import { formatTargetStatus, getGatewayTargetStatuses } from '../gateway-status.js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const { mockSend } = vi.hoisted(() => ({ + mockSend: vi.fn(), +})); + +vi.mock('@aws-sdk/client-bedrock-agentcore-control', () => ({ + BedrockAgentCoreControlClient: class { + send = mockSend; + }, + ListGatewayTargetsCommand: class { + constructor(public input: unknown) {} + }, +})); + +describe('getGatewayTargetStatuses', () => { + afterEach(() => vi.clearAllMocks()); + + it('returns statuses for all targets', async () => { + mockSend.mockResolvedValue({ + items: [ + { name: 'target-1', status: 'READY' }, + { name: 'target-2', status: 'SYNCHRONIZING' }, + { name: 'target-3', status: 'READY' }, + ], + }); + + const result = await getGatewayTargetStatuses('gw-123', 'us-east-1'); + + expect(result).toEqual([ + { name: 'target-1', status: 'READY' }, + { name: 'target-2', status: 'SYNCHRONIZING' }, + { name: 'target-3', status: 'READY' }, + ]); + }); + + it('returns empty array on API error', async () => { + mockSend.mockRejectedValue(new Error('Access denied')); + + const result = await getGatewayTargetStatuses('gw-123', 'us-east-1'); + + expect(result).toEqual([]); + }); + + it('returns empty array when no targets', async () => { + mockSend.mockResolvedValue({ items: [] }); + + const result = await getGatewayTargetStatuses('gw-123', 'us-east-1'); + + expect(result).toEqual([]); + }); + + it('handles undefined items', async () => { + mockSend.mockResolvedValue({}); + + const result = await getGatewayTargetStatuses('gw-123', 'us-east-1'); + + expect(result).toEqual([]); + }); +}); + +describe('formatTargetStatus', () => { + it('formats READY', () => { + expect(formatTargetStatus('READY')).toBe('✓ synced'); + }); + + it('formats SYNCHRONIZING', () => { + expect(formatTargetStatus('SYNCHRONIZING')).toBe('⟳ syncing...'); + }); + + it('formats SYNCHRONIZE_UNSUCCESSFUL', () => { + expect(formatTargetStatus('SYNCHRONIZE_UNSUCCESSFUL')).toBe('⚠ sync failed'); + }); + + it('formats FAILED', () => { + expect(formatTargetStatus('FAILED')).toBe('✗ failed'); + }); + + it('returns raw status for unknown values', () => { + expect(formatTargetStatus('UNKNOWN_STATUS')).toBe('UNKNOWN_STATUS'); + }); +}); diff --git a/src/cli/operations/deploy/gateway-status.ts b/src/cli/operations/deploy/gateway-status.ts new file mode 100644 index 00000000..de20b1e4 --- /dev/null +++ b/src/cli/operations/deploy/gateway-status.ts @@ -0,0 +1,42 @@ +/** + * Query gateway target sync statuses after deployment. + */ +import { BedrockAgentCoreControlClient, ListGatewayTargetsCommand } from '@aws-sdk/client-bedrock-agentcore-control'; + +export interface TargetSyncStatus { + name: string; + status: string; +} + +const STATUS_DISPLAY: Record = { + READY: '✓ synced', + SYNCHRONIZING: '⟳ syncing...', + SYNCHRONIZE_UNSUCCESSFUL: '⚠ sync failed', + CREATING: '⟳ creating...', + UPDATING: '⟳ updating...', + UPDATE_UNSUCCESSFUL: '⚠ update failed', + FAILED: '✗ failed', + DELETING: '⟳ deleting...', +}; + +export function formatTargetStatus(status: string): string { + return STATUS_DISPLAY[status] ?? status; +} + +/** + * Get sync statuses for all targets in a gateway. + * Returns empty array on error (non-blocking). + */ +export async function getGatewayTargetStatuses(gatewayId: string, region: string): Promise { + try { + const client = new BedrockAgentCoreControlClient({ region }); + const response = await client.send(new ListGatewayTargetsCommand({ gatewayIdentifier: gatewayId, maxResults: 100 })); + + return (response.items ?? []).map(target => ({ + name: target.name ?? 'unknown', + status: target.status ?? 'UNKNOWN', + })); + } catch { + return []; + } +} diff --git a/src/cli/tui/screens/deploy/DeployScreen.tsx b/src/cli/tui/screens/deploy/DeployScreen.tsx index 55e05e67..4806b73c 100644 --- a/src/cli/tui/screens/deploy/DeployScreen.tsx +++ b/src/cli/tui/screens/deploy/DeployScreen.tsx @@ -1,5 +1,6 @@ import { ConfigIO } from '../../../../lib'; import type { AgentCoreMcpSpec } from '../../../../schema'; +import { formatTargetStatus } from '../../../operations/deploy/gateway-status'; import { AwsTargetConfigUI, ConfirmPrompt, @@ -58,6 +59,7 @@ export function DeployScreen({ isInteractive, onExit, autoConfirm, onNavigate, p isComplete, hasStartedCfn, logFilePath, + targetStatuses, missingCredentials, startDeploy, confirmTeardown, @@ -279,6 +281,18 @@ export function DeployScreen({ isInteractive, onExit, autoConfirm, onNavigate, p )} + {allSuccess && targetStatuses.length > 0 && ( + + Gateway Targets: + {targetStatuses.map(t => ( + + {' '} + {t.name}: {formatTargetStatus(t.status)} + + ))} + + )} + {logFilePath && ( diff --git a/src/cli/tui/screens/deploy/useDeployFlow.ts b/src/cli/tui/screens/deploy/useDeployFlow.ts index e0783463..80b975c2 100644 --- a/src/cli/tui/screens/deploy/useDeployFlow.ts +++ b/src/cli/tui/screens/deploy/useDeployFlow.ts @@ -4,6 +4,7 @@ import { buildDeployedState, getStackOutputs, parseAgentOutputs, parseGatewayOut import { getErrorMessage, isChangesetInProgressError, isExpiredTokenError } from '../../../errors'; import { ExecLogger } from '../../../logging'; import { performStackTeardown } from '../../../operations/deploy'; +import { getGatewayTargetStatuses } from '../../../operations/deploy/gateway-status'; import { type Step, areStepsComplete, hasStepError } from '../../components'; import { type MissingCredential, type PreflightContext, useCdkPreflight } from '../../hooks'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; @@ -45,6 +46,7 @@ interface DeployFlowState { deployOutput: string | null; deployMessages: DeployMessage[]; stackOutputs: Record; + targetStatuses: { name: string; status: string }[]; hasError: boolean; /** True if the error is specifically due to expired/invalid AWS credentials */ hasTokenExpiredError: boolean; @@ -96,6 +98,7 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState const [deployOutput, setDeployOutput] = useState(null); const [deployMessages, setDeployMessages] = useState([]); const [stackOutputs, setStackOutputs] = useState>({}); + const [targetStatuses, setTargetStatuses] = useState<{ name: string; status: string }[]>([]); const [shouldStartDeploy, setShouldStartDeploy] = useState(false); const [hasTokenExpiredError, setHasTokenExpiredError] = useState(false); // Track if CloudFormation has started (received first resource event) @@ -196,6 +199,16 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState Object.keys(oauthCredentials).length > 0 ? oauthCredentials : undefined ); await configIO.writeDeployedState(deployedState); + + // Query gateway target sync statuses (non-blocking) + const allStatuses: { name: string; status: string }[] = []; + for (const [, gateway] of Object.entries(gateways)) { + const statuses = await getGatewayTargetStatuses(gateway.gatewayId, target.region); + allStatuses.push(...statuses); + } + if (allStatuses.length > 0) { + setTargetStatuses(allStatuses); + } }, [context, stackNames, logger, identityKmsKeyArn, oauthCredentials]); // Start deploy when preflight completes OR when shouldStartDeploy is set @@ -400,6 +413,7 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState deployOutput, deployMessages, stackOutputs, + targetStatuses, hasError, hasTokenExpiredError: combinedTokenExpiredError, hasCredentialsError: preflight.hasCredentialsError, From 0890e0aec24b63b8a491f845202543dfbb0578af Mon Sep 17 00:00:00 2001 From: Aidan Daly <99039782+aidandaly24@users.noreply.github.com> Date: Wed, 25 Feb 2026 10:18:23 -0500 Subject: [PATCH 13/28] refactor: remove gateway bind flow from add gateway TUI (#431) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gateways are project-level in Phase 1 — all agents get all gateways automatically via CDK env vars. The bind-agent-to-gateway flow was pre-Phase 1 code that is no longer needed. Add gateway now goes straight to the create wizard. --- src/cli/tui/screens/mcp/AddGatewayFlow.tsx | 280 +-------------------- 1 file changed, 6 insertions(+), 274 deletions(-) diff --git a/src/cli/tui/screens/mcp/AddGatewayFlow.tsx b/src/cli/tui/screens/mcp/AddGatewayFlow.tsx index 105b4f8c..8e0529c3 100644 --- a/src/cli/tui/screens/mcp/AddGatewayFlow.tsx +++ b/src/cli/tui/screens/mcp/AddGatewayFlow.tsx @@ -1,30 +1,13 @@ -import { ErrorPrompt, Panel, Screen, TextInput, WizardSelect } from '../../components'; -import type { SelectableItem } from '../../components'; -import { HELP_TEXT } from '../../constants'; -import { useListNavigation } from '../../hooks'; -import { useAgents, useAttachGateway, useGateways } from '../../hooks/useAttach'; +import { ErrorPrompt } from '../../components'; import { useCreateGateway, useExistingGateways, useUnassignedTargets } from '../../hooks/useCreateMcp'; import { AddSuccessScreen } from '../add/AddSuccessScreen'; import { AddGatewayScreen } from './AddGatewayScreen'; import type { AddGatewayConfig } from './types'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; type FlowState = - | { name: 'mode-select' } | { name: 'create-wizard' } - | { name: 'bind-select-agent' } - | { name: 'bind-select-gateway'; targetAgent: string } - | { name: 'bind-enter-name'; targetAgent: string; gatewayName: string } - | { name: 'bind-enter-description'; targetAgent: string; gatewayName: string; mcpProviderName: string } - | { - name: 'bind-enter-envvar'; - targetAgent: string; - gatewayName: string; - mcpProviderName: string; - description: string; - } | { name: 'create-success'; gatewayName: string; loading?: boolean; loadingMessage?: string } - | { name: 'bind-success'; gatewayName: string; targetAgent: string } | { name: 'error'; message: string }; interface AddGatewayFlowProps { @@ -40,11 +23,6 @@ interface AddGatewayFlowProps { onDeploy?: () => void; } -const MODE_OPTIONS: SelectableItem[] = [ - { id: 'create', title: 'Create new gateway', description: 'Define a new gateway for your project' }, - { id: 'bind', title: 'Bind existing gateway', description: 'Attach an existing gateway to an agent' }, -]; - export function AddGatewayFlow({ isInteractive = true, availableAgents, @@ -56,63 +34,17 @@ export function AddGatewayFlow({ const { createGateway, reset: resetCreate } = useCreateGateway(); const { gateways: existingGateways, refresh: refreshGateways } = useExistingGateways(); const { targets: unassignedTargets } = useUnassignedTargets(); - const [flow, setFlow] = useState({ name: 'mode-select' }); - - // Bind flow hooks - const { agents: allAgents, isLoading: isLoadingAgents } = useAgents(); - const { gateways: bindableGateways } = useGateways(); - const { attach: attachGateway } = useAttachGateway(); + const [flow, setFlow] = useState({ name: 'create-wizard' }); // In non-interactive mode, exit after success (but not while loading) useEffect(() => { if (!isInteractive) { - if ((flow.name === 'create-success' && !flow.loading) || flow.name === 'bind-success') { + if (flow.name === 'create-success' && !flow.loading) { onExit(); } } }, [isInteractive, flow, onExit]); - // Mode selection navigation - const modeNav = useListNavigation({ - items: MODE_OPTIONS, - onSelect: item => { - if (item.id === 'create') { - setFlow({ name: 'create-wizard' }); - } else { - setFlow({ name: 'bind-select-agent' }); - } - }, - onExit: onBack, - isActive: flow.name === 'mode-select', - }); - - // Agent selection for bind flow - const agentItems: SelectableItem[] = useMemo(() => allAgents.map(name => ({ id: name, title: name })), [allAgents]); - - const agentNav = useListNavigation({ - items: agentItems, - onSelect: item => setFlow({ name: 'bind-select-gateway', targetAgent: item.id }), - onExit: () => setFlow({ name: 'mode-select' }), - isActive: flow.name === 'bind-select-agent', - }); - - // Gateway selection for bind flow - const gatewayItems: SelectableItem[] = useMemo( - () => bindableGateways.map(name => ({ id: name, title: name })), - [bindableGateways] - ); - - const gatewayNav = useListNavigation({ - items: gatewayItems, - onSelect: item => { - if (flow.name === 'bind-select-gateway') { - setFlow({ name: 'bind-enter-name', targetAgent: flow.targetAgent, gatewayName: item.id }); - } - }, - onExit: () => setFlow({ name: 'bind-select-agent' }), - isActive: flow.name === 'bind-select-gateway', - }); - const handleCreateComplete = useCallback( (config: AddGatewayConfig) => { setFlow({ @@ -132,53 +64,6 @@ export function AddGatewayFlow({ [createGateway] ); - const handleBindComplete = useCallback( - async (_envVarName: string) => { - if (flow.name !== 'bind-enter-envvar') return; - - const result = await attachGateway(); - - if (result.ok) { - setFlow({ name: 'bind-success', gatewayName: flow.gatewayName, targetAgent: flow.targetAgent }); - } else { - setFlow({ name: 'error', message: 'Failed to bind gateway' }); - } - }, - [flow, attachGateway] - ); - - // Mode selection screen - if (flow.name === 'mode-select') { - // Check if there are gateways to bind - const hasGatewaysToBind = bindableGateways.length > 0; - - // If no gateways exist to bind, skip to create - if (!hasGatewaysToBind) { - return ( - - ); - } - - return ( - - - - - - ); - } - // Create wizard if (flow.name === 'create-wizard') { return ( @@ -187,148 +72,11 @@ export function AddGatewayFlow({ availableAgents={availableAgents} unassignedTargets={unassignedTargets} onComplete={handleCreateComplete} - onExit={() => setFlow({ name: 'mode-select' })} + onExit={onBack} /> ); } - // Bind flow - select agent - if (flow.name === 'bind-select-agent') { - if (isLoadingAgents) { - return null; - } - return ( - setFlow({ name: 'mode-select' })} helpText={HELP_TEXT.NAVIGATE_SELECT}> - - - - - ); - } - - // Bind flow - select gateway - if (flow.name === 'bind-select-gateway') { - return ( - setFlow({ name: 'bind-select-agent' })} - helpText={HELP_TEXT.NAVIGATE_SELECT} - > - - - - - ); - } - - // Bind flow - enter MCP provider name - if (flow.name === 'bind-enter-name') { - const defaultName = `${flow.gatewayName}-provider`; - return ( - setFlow({ name: 'bind-select-gateway', targetAgent: flow.targetAgent })} - helpText={HELP_TEXT.TEXT_INPUT} - > - - - setFlow({ - name: 'bind-enter-description', - targetAgent: flow.targetAgent, - gatewayName: flow.gatewayName, - mcpProviderName: value, - }) - } - onCancel={() => setFlow({ name: 'bind-select-gateway', targetAgent: flow.targetAgent })} - /> - - - ); - } - - // Bind flow - enter description - if (flow.name === 'bind-enter-description') { - const defaultDescription = `Tools provided by ${flow.gatewayName} gateway`; - return ( - - setFlow({ name: 'bind-enter-name', targetAgent: flow.targetAgent, gatewayName: flow.gatewayName }) - } - helpText={HELP_TEXT.TEXT_INPUT} - > - - - setFlow({ - name: 'bind-enter-envvar', - targetAgent: flow.targetAgent, - gatewayName: flow.gatewayName, - mcpProviderName: flow.mcpProviderName, - description: value, - }) - } - onCancel={() => - setFlow({ name: 'bind-enter-name', targetAgent: flow.targetAgent, gatewayName: flow.gatewayName }) - } - /> - - - ); - } - - // Bind flow - enter env var name - if (flow.name === 'bind-enter-envvar') { - const defaultEnvVar = `${flow.mcpProviderName.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_URL`; - return ( - - setFlow({ - name: 'bind-enter-description', - targetAgent: flow.targetAgent, - gatewayName: flow.gatewayName, - mcpProviderName: flow.mcpProviderName, - }) - } - helpText={HELP_TEXT.TEXT_INPUT} - > - - void handleBindComplete(value)} - onCancel={() => - setFlow({ - name: 'bind-enter-description', - targetAgent: flow.targetAgent, - gatewayName: flow.gatewayName, - mcpProviderName: flow.mcpProviderName, - }) - } - /> - - - ); - } - // Create success if (flow.name === 'create-success') { return ( @@ -349,22 +97,6 @@ export function AddGatewayFlow({ ); } - // Bind success - if (flow.name === 'bind-success') { - return ( - - ); - } - // Error return ( { resetCreate(); - setFlow({ name: 'mode-select' }); + setFlow({ name: 'create-wizard' }); }} onExit={onExit} /> From a6ac50d9526ccde5061c7e3709f7f3f1fad451d4 Mon Sep 17 00:00:00 2001 From: Aidan Daly <99039782+aidandaly24@users.noreply.github.com> Date: Wed, 25 Feb 2026 14:21:08 -0500 Subject: [PATCH 14/28] fix: prettier formatting for gateway-status.ts (#449) --- src/cli/operations/deploy/gateway-status.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/cli/operations/deploy/gateway-status.ts b/src/cli/operations/deploy/gateway-status.ts index de20b1e4..815f1115 100644 --- a/src/cli/operations/deploy/gateway-status.ts +++ b/src/cli/operations/deploy/gateway-status.ts @@ -30,7 +30,9 @@ export function formatTargetStatus(status: string): string { export async function getGatewayTargetStatuses(gatewayId: string, region: string): Promise { try { const client = new BedrockAgentCoreControlClient({ region }); - const response = await client.send(new ListGatewayTargetsCommand({ gatewayIdentifier: gatewayId, maxResults: 100 })); + const response = await client.send( + new ListGatewayTargetsCommand({ gatewayIdentifier: gatewayId, maxResults: 100 }) + ); return (response.items ?? []).map(target => ({ name: target.name ?? 'unknown', From 723f59780fe74221aada1236fac24f71cfc8b28e Mon Sep 17 00:00:00 2001 From: Aidan Daly <99039782+aidandaly24@users.noreply.github.com> Date: Wed, 25 Feb 2026 18:25:52 -0500 Subject: [PATCH 15/28] feat: add gateway auth and multi-gateway support to agent templates (#427) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add gateway auth support to agent templates Add SigV4 authentication to MCP client templates so agents can authenticate with AWS_IAM gateways. Each framework's client.py uses Handlebars conditionals to include auth when gateways exist. SigV4HTTPXAuth class signs HTTP requests using botocore SigV4Auth, passed to the MCP client via httpx.AsyncClient. Templates read gateway URLs from AGENTCORE_GATEWAY_{NAME}_URL env vars and handle missing vars gracefully (warn, don't crash). Updated all 5 frameworks: Strands, LangChain, OpenAI Agents, Google ADK, AutoGen. Schema mapper reads mcp.json to populate gateway config for template rendering. All gateways are auto- included when creating an agent. * feat: add multi-gateway support and fix template rendering Replace single-gateway [0] indexing with {{#each gatewayProviders}} loops. Each gateway gets its own client function (Strands) or entry in the servers dict (LangChain/OpenAI/AutoGen/ADK). Add snakeCase Handlebars helper for gateway function names. Add gatewayAuthTypes array for conditional SigV4 imports. Fix @index parse error by using plain variable names. * fix: update main.py files with gateway conditional imports All 5 framework main.py files now use Handlebars conditionals to import the correct MCP client function based on hasGateway flag. Fix snakeCase helper to handle all special characters. * style: fix formatting * refactor: use mcp-proxy-for-aws for gateway auth, remove AutoGen gateway support Replace custom SigV4HTTPXAuth class with official mcp-proxy-for-aws package: - Strands: aws_iam_streamablehttp_client factory pattern - LangChain: SigV4HTTPXAuth via auth param in MultiServerMCPClient config - OpenAI Agents: SigV4HTTPXAuth via httpx_client_factory param - Google ADK: SigV4HTTPXAuth via httpx_client_factory in StreamableHTTPConnectionParams Revert AutoGen to original upstream — SDK doesn't support custom httpx auth (no httpx_client_factory param). * fix: pass AWS region to aws_iam_streamablehttp_client in Strands template --- .../assets.snapshot.test.ts.snap | 251 ++++++++++++++++-- src/assets/python/googleadk/base/main.py | 11 +- .../googleadk/base/mcp_client/client.py | 36 ++- .../python/googleadk/base/pyproject.toml | 2 + .../python/langchain_langgraph/base/main.py | 12 +- .../base/mcp_client/client.py | 34 ++- .../langchain_langgraph/base/pyproject.toml | 2 + src/assets/python/openaiagents/base/main.py | 46 +++- .../openaiagents/base/mcp_client/client.py | 36 ++- .../python/openaiagents/base/pyproject.toml | 2 + src/assets/python/strands/base/main.py | 19 +- .../python/strands/base/mcp_client/client.py | 40 ++- src/assets/python/strands/base/pyproject.toml | 2 + src/cli/commands/add/actions.ts | 2 +- src/cli/commands/create/action.ts | 2 +- .../generate/__tests__/schema-mapper.test.ts | 22 +- .../agent/generate/schema-mapper.ts | 33 ++- src/cli/templates/render.ts | 3 + src/cli/templates/types.ts | 14 + src/cli/tui/screens/agent/useAddAgent.ts | 2 +- src/cli/tui/screens/create/useCreateFlow.ts | 2 +- .../tui/screens/generate/useGenerateFlow.ts | 2 +- 22 files changed, 511 insertions(+), 64 deletions(-) diff --git a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap index af1a1695..d9fefab2 100644 --- a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap +++ b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap @@ -1621,7 +1621,11 @@ from google.adk.sessions import InMemorySessionService from google.genai import types from bedrock_agentcore.runtime import BedrockAgentCoreApp from model.load import load_model +{{#if hasGateway}} +from mcp_client.client import get_all_gateway_mcp_toolsets +{{else}} from mcp_client.client import get_streamable_http_mcp_client +{{/if}} app = BedrockAgentCoreApp() log = app.logger @@ -1639,7 +1643,12 @@ def add_numbers(a: int, b: int) -> int: # Get MCP Toolset -mcp_toolset = [get_streamable_http_mcp_client()] +{{#if hasGateway}} +mcp_toolset = get_all_gateway_mcp_toolsets() +{{else}} +mcp_client = get_streamable_http_mcp_client() +mcp_toolset = [mcp_client] if mcp_client else [] +{{/if}} _credentials_loaded = False @@ -1714,21 +1723,51 @@ exports[`Assets Directory Snapshots > Python framework assets > python/python/go `; exports[`Assets Directory Snapshots > Python framework assets > python/python/googleadk/base/mcp_client/client.py should match snapshot 1`] = ` -"from google.adk.tools.mcp_tool.mcp_toolset import MCPToolset +"import os +import logging +from google.adk.tools.mcp_tool.mcp_toolset import MCPToolset from google.adk.tools.mcp_tool.mcp_session_manager import StreamableHTTPConnectionParams +logger = logging.getLogger(__name__) + +{{#if hasGateway}} +{{#if (includes gatewayAuthTypes "AWS_IAM")}} +import httpx +from mcp_proxy_for_aws.sigv4_helper import SigV4HTTPXAuth, create_aws_session +{{/if}} + +def get_all_gateway_mcp_toolsets() -> list[MCPToolset]: + """Returns MCP Toolsets for all configured gateways.""" + toolsets = [] + {{#each gatewayProviders}} + url = os.environ.get("{{envVarName}}") + if url: + {{#if (eq authType "AWS_IAM")}} + session = create_aws_session() + auth = SigV4HTTPXAuth(session.get_credentials(), "bedrock-agentcore", session.region_name) + toolsets.append(MCPToolset(connection_params=StreamableHTTPConnectionParams( + url=url, + httpx_client_factory=lambda **kwargs: httpx.AsyncClient(auth=auth, **kwargs) + ))) + {{else}} + toolsets.append(MCPToolset(connection_params=StreamableHTTPConnectionParams(url=url))) + {{/if}} + else: + logger.warning("{{envVarName}} not set — {{name}} gateway tools unavailable") + {{/each}} + return toolsets +{{else}} # ExaAI provides information about code through web searches, crawling and code context searches through their platform. Requires no authentication EXAMPLE_MCP_ENDPOINT = "https://mcp.exa.ai/mcp" def get_streamable_http_mcp_client() -> MCPToolset: - """ - Returns an MCP Toolset compatible with Google ADK. - """ + """Returns an MCP Toolset compatible with Google ADK.""" # to use an MCP server that supports bearer authentication, add headers={"Authorization": f"Bearer {access_token}"} return MCPToolset( connection_params=StreamableHTTPConnectionParams(url=EXAMPLE_MCP_ENDPOINT) ) +{{/if}} " `; @@ -1799,6 +1838,8 @@ dependencies = [ "google-adk >= 1.17.0", "bedrock-agentcore >= 1.0.3", "botocore[crt] >= 1.35.0", + {{#if hasGateway}}{{#if (includes gatewayAuthTypes "AWS_IAM")}}"mcp-proxy-for-aws >= 1.1.0", + {{/if}}{{/if}} ] [tool.hatch.build.targets.wheel] @@ -1901,7 +1942,11 @@ from langgraph.prebuilt import create_react_agent from langchain.tools import tool from bedrock_agentcore.runtime import BedrockAgentCoreApp from model.load import load_model +{{#if hasGateway}} +from mcp_client.client import get_all_gateway_mcp_client +{{else}} from mcp_client.client import get_streamable_http_mcp_client +{{/if}} app = BedrockAgentCoreApp() log = app.logger @@ -1931,10 +1976,16 @@ async def invoke(payload, context): log.info("Invoking Agent.....") # Get MCP Client + {{#if hasGateway}} + mcp_client = get_all_gateway_mcp_client() + {{else}} mcp_client = get_streamable_http_mcp_client() + {{/if}} # Load MCP Tools - mcp_tools = await mcp_client.get_tools() + mcp_tools = [] + if mcp_client: + mcp_tools = await mcp_client.get_tools() # Define the agent using create_react_agent graph = create_react_agent(get_or_create_model(), tools=mcp_tools + tools) @@ -1960,16 +2011,43 @@ exports[`Assets Directory Snapshots > Python framework assets > python/python/la `; exports[`Assets Directory Snapshots > Python framework assets > python/python/langchain_langgraph/base/mcp_client/client.py should match snapshot 1`] = ` -"from langchain_mcp_adapters.client import MultiServerMCPClient +"import os +import logging +from langchain_mcp_adapters.client import MultiServerMCPClient + +logger = logging.getLogger(__name__) + +{{#if hasGateway}} +{{#if (includes gatewayAuthTypes "AWS_IAM")}} +from mcp_proxy_for_aws.sigv4_helper import SigV4HTTPXAuth, create_aws_session +{{/if}} +def get_all_gateway_mcp_client() -> MultiServerMCPClient | None: + """Returns an MCP Client connected to all configured gateways.""" + servers = {} + {{#each gatewayProviders}} + url = os.environ.get("{{envVarName}}") + if url: + {{#if (eq authType "AWS_IAM")}} + session = create_aws_session() + auth = SigV4HTTPXAuth(session.get_credentials(), "bedrock-agentcore", session.region_name) + servers["{{name}}"] = {"transport": "streamable_http", "url": url, "auth": auth} + {{else}} + servers["{{name}}"] = {"transport": "streamable_http", "url": url} + {{/if}} + else: + logger.warning("{{envVarName}} not set — {{name}} gateway tools unavailable") + {{/each}} + if not servers: + return None + return MultiServerMCPClient(servers) +{{else}} # ExaAI provides information about code through web searches, crawling and code context searches through their platform. Requires no authentication EXAMPLE_MCP_ENDPOINT = "https://mcp.exa.ai/mcp" def get_streamable_http_mcp_client() -> MultiServerMCPClient: - """ - Returns an MCP Client compatible with LangChain/LangGraph. - """ + """Returns an MCP Client compatible with LangChain/LangGraph.""" # to use an MCP server that supports bearer authentication, add headers={"Authorization": f"Bearer {access_token}"} return MultiServerMCPClient( { @@ -1979,6 +2057,7 @@ def get_streamable_http_mcp_client() -> MultiServerMCPClient: } } ) +{{/if}} " `; @@ -2146,6 +2225,8 @@ dependencies = [ {{#if (eq modelProvider "Gemini")}} "langchain-google-genai >= 3.0.3", {{/if}} + {{#if hasGateway}}{{#if (includes gatewayAuthTypes "AWS_IAM")}}"mcp-proxy-for-aws >= 1.1.0", + {{/if}}{{/if}} ] [tool.hatch.build.targets.wheel] @@ -2246,13 +2327,22 @@ exports[`Assets Directory Snapshots > Python framework assets > python/python/op from agents import Agent, Runner, function_tool from bedrock_agentcore.runtime import BedrockAgentCoreApp from model.load import load_model +{{#if hasGateway}} +from mcp_client.client import get_all_gateway_mcp_servers +{{else}} from mcp_client.client import get_streamable_http_mcp_client +{{/if}} app = BedrockAgentCoreApp() log = app.logger # Get MCP Server +{{#if hasGateway}} +mcp_servers = get_all_gateway_mcp_servers() +{{else}} mcp_server = get_streamable_http_mcp_client() +mcp_servers = [mcp_server] if mcp_server else [] +{{/if}} _credentials_loaded = False @@ -2274,16 +2364,47 @@ def add_numbers(a: int, b: int) -> int: async def main(query): ensure_credentials_loaded() try: - async with mcp_server as server: - active_servers = [server] if server else [] + {{#if hasGateway}} + if mcp_servers: + agent = Agent( + name="{{ name }}", + model="gpt-4.1", + mcp_servers=mcp_servers, + tools=[add_numbers] + ) + result = await Runner.run(agent, query) + return result + else: + agent = Agent( + name="{{ name }}", + model="gpt-4.1", + mcp_servers=[], + tools=[add_numbers] + ) + result = await Runner.run(agent, query) + return result + {{else}} + if mcp_servers: + async with mcp_servers[0] as server: + active_servers = [server] + agent = Agent( + name="{{ name }}", + model="gpt-4.1", + mcp_servers=active_servers, + tools=[add_numbers] + ) + result = await Runner.run(agent, query) + return result + else: agent = Agent( name="{{ name }}", model="gpt-4.1", - mcp_servers=active_servers, + mcp_servers=[], tools=[add_numbers] ) result = await Runner.run(agent, query) return result + {{/if}} except Exception as e: log.error(f"Error during agent execution: {e}", exc_info=True) raise e @@ -2314,20 +2435,50 @@ exports[`Assets Directory Snapshots > Python framework assets > python/python/op `; exports[`Assets Directory Snapshots > Python framework assets > python/python/openaiagents/base/mcp_client/client.py should match snapshot 1`] = ` -"from agents.mcp import MCPServerStreamableHttp +"import os +import logging +from agents.mcp import MCPServerStreamableHttp + +logger = logging.getLogger(__name__) +{{#if hasGateway}} +{{#if (includes gatewayAuthTypes "AWS_IAM")}} +import httpx +from mcp_proxy_for_aws.sigv4_helper import SigV4HTTPXAuth, create_aws_session +{{/if}} + +def get_all_gateway_mcp_servers() -> list[MCPServerStreamableHttp]: + """Returns MCP servers for all configured gateways.""" + servers = [] + {{#each gatewayProviders}} + url = os.environ.get("{{envVarName}}") + if url: + {{#if (eq authType "AWS_IAM")}} + session = create_aws_session() + auth = SigV4HTTPXAuth(session.get_credentials(), "bedrock-agentcore", session.region_name) + servers.append(MCPServerStreamableHttp( + name="{{name}}", + params={"url": url, "httpx_client_factory": lambda **kwargs: httpx.AsyncClient(auth=auth, **kwargs)} + )) + {{else}} + servers.append(MCPServerStreamableHttp(name="{{name}}", params={"url": url})) + {{/if}} + else: + logger.warning("{{envVarName}} not set — {{name}} gateway tools unavailable") + {{/each}} + return servers +{{else}} # ExaAI provides information about code through web searches, crawling and code context searches through their platform. Requires no authentication EXAMPLE_MCP_ENDPOINT = "https://mcp.exa.ai/mcp" def get_streamable_http_mcp_client() -> MCPServerStreamableHttp: - """ - Returns an MCP Client compatible with OpenAI Agents SDK. - """ + """Returns an MCP Client compatible with OpenAI Agents SDK.""" # to use an MCP server that supports bearer authentication, add headers={"Authorization": f"Bearer {access_token}"} return MCPServerStreamableHttp( name="AgentCore Gateway MCP", params={"url": EXAMPLE_MCP_ENDPOINT} ) +{{/if}} " `; @@ -2393,6 +2544,8 @@ dependencies = [ "openai-agents >= 0.4.2", "bedrock-agentcore >= 1.0.3", "botocore[crt] >= 1.35.0", + {{#if hasGateway}}{{#if (includes gatewayAuthTypes "AWS_IAM")}}"mcp-proxy-for-aws >= 1.1.0", + {{/if}}{{/if}} ] [tool.hatch.build.targets.wheel] @@ -2491,7 +2644,11 @@ exports[`Assets Directory Snapshots > Python framework assets > python/python/st "from strands import Agent, tool from bedrock_agentcore.runtime import BedrockAgentCoreApp from model.load import load_model +{{#if hasGateway}} +from mcp_client.client import get_all_gateway_mcp_clients +{{else}} from mcp_client.client import get_streamable_http_mcp_client +{{/if}} {{#if hasMemory}} from memory.session import get_memory_session_manager {{/if}} @@ -2500,7 +2657,11 @@ app = BedrockAgentCoreApp() log = app.logger # Define a Streamable HTTP MCP Client -mcp_client = get_streamable_http_mcp_client() +{{#if hasGateway}} +mcp_clients = get_all_gateway_mcp_clients() +{{else}} +mcp_clients = [get_streamable_http_mcp_client()] +{{/if}} # Define a collection of tools used by the model tools = [] @@ -2512,6 +2673,11 @@ def add_numbers(a: int, b: int) -> int: return a+b tools.append(add_numbers) +# Add MCP client to tools if available +for mcp_client in mcp_clients: + if mcp_client: + tools.append(mcp_client) + {{#if hasMemory}} def agent_factory(): @@ -2526,7 +2692,7 @@ def agent_factory(): system_prompt=""" You are a helpful assistant. Use tools when appropriate. """, - tools=tools+[mcp_client] + tools=tools ) return cache[key] return get_or_create_agent @@ -2542,7 +2708,7 @@ def get_or_create_agent(): system_prompt=""" You are a helpful assistant. Use tools when appropriate. """, - tools=tools+[mcp_client] + tools=tools ) return _agent {{/if}} @@ -2580,18 +2746,51 @@ exports[`Assets Directory Snapshots > Python framework assets > python/python/st `; exports[`Assets Directory Snapshots > Python framework assets > python/python/strands/base/mcp_client/client.py should match snapshot 1`] = ` -"from mcp.client.streamable_http import streamablehttp_client +"import os +import logging +from mcp.client.streamable_http import streamablehttp_client from strands.tools.mcp.mcp_client import MCPClient +logger = logging.getLogger(__name__) + +{{#if hasGateway}} +{{#if (includes gatewayAuthTypes "AWS_IAM")}} +from mcp_proxy_for_aws.client import aws_iam_streamablehttp_client +{{/if}} + +{{#each gatewayProviders}} +def get_{{snakeCase name}}_mcp_client() -> MCPClient | None: + """Returns an MCP Client connected to the {{name}} gateway.""" + url = os.environ.get("{{envVarName}}") + if not url: + logger.warning("{{envVarName}} not set — {{name}} gateway tools unavailable") + return None + {{#if (eq authType "AWS_IAM")}} + return MCPClient(lambda: aws_iam_streamablehttp_client(url, aws_service="bedrock-agentcore", aws_region=os.environ.get("AWS_REGION", os.environ.get("AWS_DEFAULT_REGION")))) + {{else}} + return MCPClient(lambda: streamablehttp_client(url)) + {{/if}} + +{{/each}} +def get_all_gateway_mcp_clients() -> list[MCPClient]: + """Returns MCP clients for all configured gateways.""" + clients = [] + {{#each gatewayProviders}} + client = get_{{snakeCase name}}_mcp_client() + if client: + clients.append(client) + {{/each}} + return clients +{{else}} # ExaAI provides information about code through web searches, crawling and code context searches through their platform. Requires no authentication EXAMPLE_MCP_ENDPOINT = "https://mcp.exa.ai/mcp" def get_streamable_http_mcp_client() -> MCPClient: - """ - Returns an MCP Client compatible with Strands - """ + """Returns an MCP Client compatible with Strands""" # to use an MCP server that supports bearer authentication, add headers={"Authorization": f"Bearer {access_token}"} - return MCPClient(lambda: streamablehttp_client(EXAMPLE_MCP_ENDPOINT))" + return MCPClient(lambda: streamablehttp_client(EXAMPLE_MCP_ENDPOINT)) +{{/if}} +" `; exports[`Assets Directory Snapshots > Python framework assets > python/python/strands/base/model/__init__.py should match snapshot 1`] = ` @@ -2746,6 +2945,8 @@ dependencies = [ {{/if}}"mcp >= 1.19.0", {{#if (eq modelProvider "OpenAI")}}"openai >= 1.0.0", {{/if}}"strands-agents >= 1.13.0", + {{#if hasGateway}}{{#if (includes gatewayAuthTypes "AWS_IAM")}}"mcp-proxy-for-aws >= 1.1.0", + {{/if}}{{/if}} ] [tool.hatch.build.targets.wheel] diff --git a/src/assets/python/googleadk/base/main.py b/src/assets/python/googleadk/base/main.py index 2e89f01a..5ce99608 100644 --- a/src/assets/python/googleadk/base/main.py +++ b/src/assets/python/googleadk/base/main.py @@ -5,7 +5,11 @@ from google.genai import types from bedrock_agentcore.runtime import BedrockAgentCoreApp from model.load import load_model +{{#if hasGateway}} +from mcp_client.client import get_all_gateway_mcp_toolsets +{{else}} from mcp_client.client import get_streamable_http_mcp_client +{{/if}} app = BedrockAgentCoreApp() log = app.logger @@ -23,7 +27,12 @@ def add_numbers(a: int, b: int) -> int: # Get MCP Toolset -mcp_toolset = [get_streamable_http_mcp_client()] +{{#if hasGateway}} +mcp_toolset = get_all_gateway_mcp_toolsets() +{{else}} +mcp_client = get_streamable_http_mcp_client() +mcp_toolset = [mcp_client] if mcp_client else [] +{{/if}} _credentials_loaded = False diff --git a/src/assets/python/googleadk/base/mcp_client/client.py b/src/assets/python/googleadk/base/mcp_client/client.py index 777e2836..f2c1a39c 100644 --- a/src/assets/python/googleadk/base/mcp_client/client.py +++ b/src/assets/python/googleadk/base/mcp_client/client.py @@ -1,15 +1,45 @@ +import os +import logging from google.adk.tools.mcp_tool.mcp_toolset import MCPToolset from google.adk.tools.mcp_tool.mcp_session_manager import StreamableHTTPConnectionParams +logger = logging.getLogger(__name__) + +{{#if hasGateway}} +{{#if (includes gatewayAuthTypes "AWS_IAM")}} +import httpx +from mcp_proxy_for_aws.sigv4_helper import SigV4HTTPXAuth, create_aws_session +{{/if}} + +def get_all_gateway_mcp_toolsets() -> list[MCPToolset]: + """Returns MCP Toolsets for all configured gateways.""" + toolsets = [] + {{#each gatewayProviders}} + url = os.environ.get("{{envVarName}}") + if url: + {{#if (eq authType "AWS_IAM")}} + session = create_aws_session() + auth = SigV4HTTPXAuth(session.get_credentials(), "bedrock-agentcore", session.region_name) + toolsets.append(MCPToolset(connection_params=StreamableHTTPConnectionParams( + url=url, + httpx_client_factory=lambda **kwargs: httpx.AsyncClient(auth=auth, **kwargs) + ))) + {{else}} + toolsets.append(MCPToolset(connection_params=StreamableHTTPConnectionParams(url=url))) + {{/if}} + else: + logger.warning("{{envVarName}} not set — {{name}} gateway tools unavailable") + {{/each}} + return toolsets +{{else}} # ExaAI provides information about code through web searches, crawling and code context searches through their platform. Requires no authentication EXAMPLE_MCP_ENDPOINT = "https://mcp.exa.ai/mcp" def get_streamable_http_mcp_client() -> MCPToolset: - """ - Returns an MCP Toolset compatible with Google ADK. - """ + """Returns an MCP Toolset compatible with Google ADK.""" # to use an MCP server that supports bearer authentication, add headers={"Authorization": f"Bearer {access_token}"} return MCPToolset( connection_params=StreamableHTTPConnectionParams(url=EXAMPLE_MCP_ENDPOINT) ) +{{/if}} diff --git a/src/assets/python/googleadk/base/pyproject.toml b/src/assets/python/googleadk/base/pyproject.toml index 49075500..98fd161e 100644 --- a/src/assets/python/googleadk/base/pyproject.toml +++ b/src/assets/python/googleadk/base/pyproject.toml @@ -14,6 +14,8 @@ dependencies = [ "google-adk >= 1.17.0", "bedrock-agentcore >= 1.0.3", "botocore[crt] >= 1.35.0", + {{#if hasGateway}}{{#if (includes gatewayAuthTypes "AWS_IAM")}}"mcp-proxy-for-aws >= 1.1.0", + {{/if}}{{/if}} ] [tool.hatch.build.targets.wheel] diff --git a/src/assets/python/langchain_langgraph/base/main.py b/src/assets/python/langchain_langgraph/base/main.py index 88bfe2d8..3047d124 100644 --- a/src/assets/python/langchain_langgraph/base/main.py +++ b/src/assets/python/langchain_langgraph/base/main.py @@ -4,7 +4,11 @@ from langchain.tools import tool from bedrock_agentcore.runtime import BedrockAgentCoreApp from model.load import load_model +{{#if hasGateway}} +from mcp_client.client import get_all_gateway_mcp_client +{{else}} from mcp_client.client import get_streamable_http_mcp_client +{{/if}} app = BedrockAgentCoreApp() log = app.logger @@ -34,10 +38,16 @@ async def invoke(payload, context): log.info("Invoking Agent.....") # Get MCP Client + {{#if hasGateway}} + mcp_client = get_all_gateway_mcp_client() + {{else}} mcp_client = get_streamable_http_mcp_client() + {{/if}} # Load MCP Tools - mcp_tools = await mcp_client.get_tools() + mcp_tools = [] + if mcp_client: + mcp_tools = await mcp_client.get_tools() # Define the agent using create_react_agent graph = create_react_agent(get_or_create_model(), tools=mcp_tools + tools) diff --git a/src/assets/python/langchain_langgraph/base/mcp_client/client.py b/src/assets/python/langchain_langgraph/base/mcp_client/client.py index 1e870204..adcb478a 100644 --- a/src/assets/python/langchain_langgraph/base/mcp_client/client.py +++ b/src/assets/python/langchain_langgraph/base/mcp_client/client.py @@ -1,13 +1,40 @@ +import os +import logging from langchain_mcp_adapters.client import MultiServerMCPClient +logger = logging.getLogger(__name__) + +{{#if hasGateway}} +{{#if (includes gatewayAuthTypes "AWS_IAM")}} +from mcp_proxy_for_aws.sigv4_helper import SigV4HTTPXAuth, create_aws_session +{{/if}} + +def get_all_gateway_mcp_client() -> MultiServerMCPClient | None: + """Returns an MCP Client connected to all configured gateways.""" + servers = {} + {{#each gatewayProviders}} + url = os.environ.get("{{envVarName}}") + if url: + {{#if (eq authType "AWS_IAM")}} + session = create_aws_session() + auth = SigV4HTTPXAuth(session.get_credentials(), "bedrock-agentcore", session.region_name) + servers["{{name}}"] = {"transport": "streamable_http", "url": url, "auth": auth} + {{else}} + servers["{{name}}"] = {"transport": "streamable_http", "url": url} + {{/if}} + else: + logger.warning("{{envVarName}} not set — {{name}} gateway tools unavailable") + {{/each}} + if not servers: + return None + return MultiServerMCPClient(servers) +{{else}} # ExaAI provides information about code through web searches, crawling and code context searches through their platform. Requires no authentication EXAMPLE_MCP_ENDPOINT = "https://mcp.exa.ai/mcp" def get_streamable_http_mcp_client() -> MultiServerMCPClient: - """ - Returns an MCP Client compatible with LangChain/LangGraph. - """ + """Returns an MCP Client compatible with LangChain/LangGraph.""" # to use an MCP server that supports bearer authentication, add headers={"Authorization": f"Bearer {access_token}"} return MultiServerMCPClient( { @@ -17,3 +44,4 @@ def get_streamable_http_mcp_client() -> MultiServerMCPClient: } } ) +{{/if}} diff --git a/src/assets/python/langchain_langgraph/base/pyproject.toml b/src/assets/python/langchain_langgraph/base/pyproject.toml index 2fd6401a..fb75744a 100644 --- a/src/assets/python/langchain_langgraph/base/pyproject.toml +++ b/src/assets/python/langchain_langgraph/base/pyproject.toml @@ -29,6 +29,8 @@ dependencies = [ {{#if (eq modelProvider "Gemini")}} "langchain-google-genai >= 3.0.3", {{/if}} + {{#if hasGateway}}{{#if (includes gatewayAuthTypes "AWS_IAM")}}"mcp-proxy-for-aws >= 1.1.0", + {{/if}}{{/if}} ] [tool.hatch.build.targets.wheel] diff --git a/src/assets/python/openaiagents/base/main.py b/src/assets/python/openaiagents/base/main.py index d75f6002..57f49755 100644 --- a/src/assets/python/openaiagents/base/main.py +++ b/src/assets/python/openaiagents/base/main.py @@ -2,13 +2,22 @@ from agents import Agent, Runner, function_tool from bedrock_agentcore.runtime import BedrockAgentCoreApp from model.load import load_model +{{#if hasGateway}} +from mcp_client.client import get_all_gateway_mcp_servers +{{else}} from mcp_client.client import get_streamable_http_mcp_client +{{/if}} app = BedrockAgentCoreApp() log = app.logger # Get MCP Server +{{#if hasGateway}} +mcp_servers = get_all_gateway_mcp_servers() +{{else}} mcp_server = get_streamable_http_mcp_client() +mcp_servers = [mcp_server] if mcp_server else [] +{{/if}} _credentials_loaded = False @@ -30,16 +39,47 @@ def add_numbers(a: int, b: int) -> int: async def main(query): ensure_credentials_loaded() try: - async with mcp_server as server: - active_servers = [server] if server else [] + {{#if hasGateway}} + if mcp_servers: agent = Agent( name="{{ name }}", model="gpt-4.1", - mcp_servers=active_servers, + mcp_servers=mcp_servers, tools=[add_numbers] ) result = await Runner.run(agent, query) return result + else: + agent = Agent( + name="{{ name }}", + model="gpt-4.1", + mcp_servers=[], + tools=[add_numbers] + ) + result = await Runner.run(agent, query) + return result + {{else}} + if mcp_servers: + async with mcp_servers[0] as server: + active_servers = [server] + agent = Agent( + name="{{ name }}", + model="gpt-4.1", + mcp_servers=active_servers, + tools=[add_numbers] + ) + result = await Runner.run(agent, query) + return result + else: + agent = Agent( + name="{{ name }}", + model="gpt-4.1", + mcp_servers=[], + tools=[add_numbers] + ) + result = await Runner.run(agent, query) + return result + {{/if}} except Exception as e: log.error(f"Error during agent execution: {e}", exc_info=True) raise e diff --git a/src/assets/python/openaiagents/base/mcp_client/client.py b/src/assets/python/openaiagents/base/mcp_client/client.py index 9796d575..39612c38 100644 --- a/src/assets/python/openaiagents/base/mcp_client/client.py +++ b/src/assets/python/openaiagents/base/mcp_client/client.py @@ -1,14 +1,44 @@ +import os +import logging from agents.mcp import MCPServerStreamableHttp +logger = logging.getLogger(__name__) + +{{#if hasGateway}} +{{#if (includes gatewayAuthTypes "AWS_IAM")}} +import httpx +from mcp_proxy_for_aws.sigv4_helper import SigV4HTTPXAuth, create_aws_session +{{/if}} + +def get_all_gateway_mcp_servers() -> list[MCPServerStreamableHttp]: + """Returns MCP servers for all configured gateways.""" + servers = [] + {{#each gatewayProviders}} + url = os.environ.get("{{envVarName}}") + if url: + {{#if (eq authType "AWS_IAM")}} + session = create_aws_session() + auth = SigV4HTTPXAuth(session.get_credentials(), "bedrock-agentcore", session.region_name) + servers.append(MCPServerStreamableHttp( + name="{{name}}", + params={"url": url, "httpx_client_factory": lambda **kwargs: httpx.AsyncClient(auth=auth, **kwargs)} + )) + {{else}} + servers.append(MCPServerStreamableHttp(name="{{name}}", params={"url": url})) + {{/if}} + else: + logger.warning("{{envVarName}} not set — {{name}} gateway tools unavailable") + {{/each}} + return servers +{{else}} # ExaAI provides information about code through web searches, crawling and code context searches through their platform. Requires no authentication EXAMPLE_MCP_ENDPOINT = "https://mcp.exa.ai/mcp" def get_streamable_http_mcp_client() -> MCPServerStreamableHttp: - """ - Returns an MCP Client compatible with OpenAI Agents SDK. - """ + """Returns an MCP Client compatible with OpenAI Agents SDK.""" # to use an MCP server that supports bearer authentication, add headers={"Authorization": f"Bearer {access_token}"} return MCPServerStreamableHttp( name="AgentCore Gateway MCP", params={"url": EXAMPLE_MCP_ENDPOINT} ) +{{/if}} diff --git a/src/assets/python/openaiagents/base/pyproject.toml b/src/assets/python/openaiagents/base/pyproject.toml index 1f535123..61944b9a 100644 --- a/src/assets/python/openaiagents/base/pyproject.toml +++ b/src/assets/python/openaiagents/base/pyproject.toml @@ -13,6 +13,8 @@ dependencies = [ "openai-agents >= 0.4.2", "bedrock-agentcore >= 1.0.3", "botocore[crt] >= 1.35.0", + {{#if hasGateway}}{{#if (includes gatewayAuthTypes "AWS_IAM")}}"mcp-proxy-for-aws >= 1.1.0", + {{/if}}{{/if}} ] [tool.hatch.build.targets.wheel] diff --git a/src/assets/python/strands/base/main.py b/src/assets/python/strands/base/main.py index a5557405..21546915 100644 --- a/src/assets/python/strands/base/main.py +++ b/src/assets/python/strands/base/main.py @@ -1,7 +1,11 @@ from strands import Agent, tool from bedrock_agentcore.runtime import BedrockAgentCoreApp from model.load import load_model +{{#if hasGateway}} +from mcp_client.client import get_all_gateway_mcp_clients +{{else}} from mcp_client.client import get_streamable_http_mcp_client +{{/if}} {{#if hasMemory}} from memory.session import get_memory_session_manager {{/if}} @@ -10,7 +14,11 @@ log = app.logger # Define a Streamable HTTP MCP Client -mcp_client = get_streamable_http_mcp_client() +{{#if hasGateway}} +mcp_clients = get_all_gateway_mcp_clients() +{{else}} +mcp_clients = [get_streamable_http_mcp_client()] +{{/if}} # Define a collection of tools used by the model tools = [] @@ -22,6 +30,11 @@ def add_numbers(a: int, b: int) -> int: return a+b tools.append(add_numbers) +# Add MCP client to tools if available +for mcp_client in mcp_clients: + if mcp_client: + tools.append(mcp_client) + {{#if hasMemory}} def agent_factory(): @@ -36,7 +49,7 @@ def get_or_create_agent(session_id, user_id): system_prompt=""" You are a helpful assistant. Use tools when appropriate. """, - tools=tools+[mcp_client] + tools=tools ) return cache[key] return get_or_create_agent @@ -52,7 +65,7 @@ def get_or_create_agent(): system_prompt=""" You are a helpful assistant. Use tools when appropriate. """, - tools=tools+[mcp_client] + tools=tools ) return _agent {{/if}} diff --git a/src/assets/python/strands/base/mcp_client/client.py b/src/assets/python/strands/base/mcp_client/client.py index cf292870..3b77cdac 100644 --- a/src/assets/python/strands/base/mcp_client/client.py +++ b/src/assets/python/strands/base/mcp_client/client.py @@ -1,12 +1,44 @@ +import os +import logging from mcp.client.streamable_http import streamablehttp_client from strands.tools.mcp.mcp_client import MCPClient +logger = logging.getLogger(__name__) + +{{#if hasGateway}} +{{#if (includes gatewayAuthTypes "AWS_IAM")}} +from mcp_proxy_for_aws.client import aws_iam_streamablehttp_client +{{/if}} + +{{#each gatewayProviders}} +def get_{{snakeCase name}}_mcp_client() -> MCPClient | None: + """Returns an MCP Client connected to the {{name}} gateway.""" + url = os.environ.get("{{envVarName}}") + if not url: + logger.warning("{{envVarName}} not set — {{name}} gateway tools unavailable") + return None + {{#if (eq authType "AWS_IAM")}} + return MCPClient(lambda: aws_iam_streamablehttp_client(url, aws_service="bedrock-agentcore", aws_region=os.environ.get("AWS_REGION", os.environ.get("AWS_DEFAULT_REGION")))) + {{else}} + return MCPClient(lambda: streamablehttp_client(url)) + {{/if}} + +{{/each}} +def get_all_gateway_mcp_clients() -> list[MCPClient]: + """Returns MCP clients for all configured gateways.""" + clients = [] + {{#each gatewayProviders}} + client = get_{{snakeCase name}}_mcp_client() + if client: + clients.append(client) + {{/each}} + return clients +{{else}} # ExaAI provides information about code through web searches, crawling and code context searches through their platform. Requires no authentication EXAMPLE_MCP_ENDPOINT = "https://mcp.exa.ai/mcp" def get_streamable_http_mcp_client() -> MCPClient: - """ - Returns an MCP Client compatible with Strands - """ + """Returns an MCP Client compatible with Strands""" # to use an MCP server that supports bearer authentication, add headers={"Authorization": f"Bearer {access_token}"} - return MCPClient(lambda: streamablehttp_client(EXAMPLE_MCP_ENDPOINT)) \ No newline at end of file + return MCPClient(lambda: streamablehttp_client(EXAMPLE_MCP_ENDPOINT)) +{{/if}} diff --git a/src/assets/python/strands/base/pyproject.toml b/src/assets/python/strands/base/pyproject.toml index 3e0b4e6c..d2174547 100644 --- a/src/assets/python/strands/base/pyproject.toml +++ b/src/assets/python/strands/base/pyproject.toml @@ -17,6 +17,8 @@ dependencies = [ {{/if}}"mcp >= 1.19.0", {{#if (eq modelProvider "OpenAI")}}"openai >= 1.0.0", {{/if}}"strands-agents >= 1.13.0", + {{#if hasGateway}}{{#if (includes gatewayAuthTypes "AWS_IAM")}}"mcp-proxy-for-aws >= 1.1.0", + {{/if}}{{/if}} ] [tool.hatch.build.targets.wheel] diff --git a/src/cli/commands/add/actions.ts b/src/cli/commands/add/actions.ts index 0d52fd3f..5394a685 100644 --- a/src/cli/commands/add/actions.ts +++ b/src/cli/commands/add/actions.ts @@ -160,7 +160,7 @@ async function handleCreatePath(options: ValidatedAddAgentOptions, configBaseDir } // Render templates with correct identity provider - const renderConfig = mapGenerateConfigToRenderConfig(generateConfig, identityProviders); + const renderConfig = await mapGenerateConfigToRenderConfig(generateConfig, identityProviders); const renderer = createRenderer(renderConfig); await renderer.render({ outputDir: projectRoot }); diff --git a/src/cli/commands/create/action.ts b/src/cli/commands/create/action.ts index da4d40c2..e6865ca2 100644 --- a/src/cli/commands/create/action.ts +++ b/src/cli/commands/create/action.ts @@ -197,7 +197,7 @@ export async function createProjectWithAgent(options: CreateWithAgentOptions): P } // Generate agent code with correct identity provider - const renderConfig = mapGenerateConfigToRenderConfig(generateConfig, identityProviders); + const renderConfig = await mapGenerateConfigToRenderConfig(generateConfig, identityProviders); const renderer = createRenderer(renderConfig); await renderer.render({ outputDir: projectRoot }); diff --git a/src/cli/operations/agent/generate/__tests__/schema-mapper.test.ts b/src/cli/operations/agent/generate/__tests__/schema-mapper.test.ts index 40faf584..593c9520 100644 --- a/src/cli/operations/agent/generate/__tests__/schema-mapper.test.ts +++ b/src/cli/operations/agent/generate/__tests__/schema-mapper.test.ts @@ -148,43 +148,45 @@ describe('mapModelProviderToIdentityProviders', () => { }); describe('mapGenerateConfigToRenderConfig', () => { - it('maps config with no memory and no identity', () => { - const result = mapGenerateConfigToRenderConfig(baseConfig, []); + it('maps config with no memory and no identity', async () => { + const result = await mapGenerateConfigToRenderConfig(baseConfig, []); expect(result.name).toBe('TestProject'); expect(result.sdkFramework).toBe('Strands'); expect(result.targetLanguage).toBe('Python'); expect(result.modelProvider).toBe('Bedrock'); expect(result.hasMemory).toBe(false); expect(result.hasIdentity).toBe(false); + expect(result.hasGateway).toBe(false); expect(result.memoryProviders).toEqual([]); expect(result.identityProviders).toEqual([]); + expect(result.gatewayProviders).toEqual([]); }); - it('sets hasMemory true when memory is not "none"', () => { + it('sets hasMemory true when memory is not "none"', async () => { const config: GenerateConfig = { ...baseConfig, memory: 'shortTerm' }; - const result = mapGenerateConfigToRenderConfig(config, []); + const result = await mapGenerateConfigToRenderConfig(config, []); expect(result.hasMemory).toBe(true); }); - it('sets hasIdentity true when identity providers exist', () => { + it('sets hasIdentity true when identity providers exist', async () => { const identityProviders = [{ name: 'ProjAnthropic', envVarName: 'AGENTCORE_CREDENTIAL_PROJANTHROPIC' }]; - const result = mapGenerateConfigToRenderConfig(baseConfig, identityProviders); + const result = await mapGenerateConfigToRenderConfig(baseConfig, identityProviders); expect(result.hasIdentity).toBe(true); expect(result.identityProviders).toEqual(identityProviders); }); - it('populates memoryProviders for shortTerm memory', () => { + it('populates memoryProviders for shortTerm memory', async () => { const config: GenerateConfig = { ...baseConfig, memory: 'shortTerm' }; - const result = mapGenerateConfigToRenderConfig(config, []); + const result = await mapGenerateConfigToRenderConfig(config, []); expect(result.memoryProviders).toHaveLength(1); expect(result.memoryProviders[0]!.name).toBe('TestProjectMemory'); expect(result.memoryProviders[0]!.envVarName).toBe('MEMORY_TESTPROJECTMEMORY_ID'); expect(result.memoryProviders[0]!.strategies).toEqual([]); }); - it('populates memoryProviders with strategy types for longAndShortTerm', () => { + it('populates memoryProviders with strategy types for longAndShortTerm', async () => { const config: GenerateConfig = { ...baseConfig, memory: 'longAndShortTerm' }; - const result = mapGenerateConfigToRenderConfig(config, []); + const result = await mapGenerateConfigToRenderConfig(config, []); expect(result.memoryProviders[0]!.strategies).toEqual(['SEMANTIC', 'USER_PREFERENCE', 'SUMMARIZATION']); }); }); diff --git a/src/cli/operations/agent/generate/schema-mapper.ts b/src/cli/operations/agent/generate/schema-mapper.ts index d4a6ae1b..996fab70 100644 --- a/src/cli/operations/agent/generate/schema-mapper.ts +++ b/src/cli/operations/agent/generate/schema-mapper.ts @@ -1,4 +1,4 @@ -import { APP_DIR } from '../../../../lib'; +import { APP_DIR, ConfigIO } from '../../../../lib'; import type { AgentEnvSpec, Credential, @@ -12,6 +12,7 @@ import type { import { DEFAULT_STRATEGY_NAMESPACES } from '../../../../schema'; import type { AgentRenderConfig, + GatewayProviderRenderConfig, IdentityProviderRenderConfig, MemoryProviderRenderConfig, } from '../../../templates/types'; @@ -23,6 +24,7 @@ import { } from '../../../tui/screens/generate/defaults'; import type { GenerateConfig, MemoryOption } from '../../../tui/screens/generate/types'; import { computeDefaultCredentialEnvVarName } from '../../identity/create-identity'; +import { computeDefaultGatewayEnvVarName } from '../../mcp/create-mcp'; /** * Result of mapping GenerateConfig to v2 schema. @@ -176,15 +178,37 @@ export function mapModelProviderToIdentityProviders( ]; } +/** + * Maps MCP gateways to gateway providers for template rendering. + */ +async function mapMcpGatewaysToGatewayProviders(): Promise { + try { + const configIO = new ConfigIO(); + if (!configIO.configExists('mcp')) { + return []; + } + const mcpSpec = await configIO.readMcpSpec(); + return mcpSpec.agentCoreGateways.map(gateway => ({ + name: gateway.name, + envVarName: computeDefaultGatewayEnvVarName(gateway.name), + authType: gateway.authorizerType, + })); + } catch { + return []; + } +} + /** * Maps GenerateConfig to AgentRenderConfig for template rendering. * @param config - Generate config (note: config.projectName is actually the agent name) * @param identityProviders - Identity providers to include (caller controls credential naming) */ -export function mapGenerateConfigToRenderConfig( +export async function mapGenerateConfigToRenderConfig( config: GenerateConfig, identityProviders: IdentityProviderRenderConfig[] -): AgentRenderConfig { +): Promise { + const gatewayProviders = await mapMcpGatewaysToGatewayProviders(); + return { name: config.projectName, sdkFramework: config.sdk, @@ -192,8 +216,11 @@ export function mapGenerateConfigToRenderConfig( modelProvider: config.modelProvider, hasMemory: config.memory !== 'none', hasIdentity: identityProviders.length > 0, + hasGateway: gatewayProviders.length > 0, buildType: config.buildType, memoryProviders: mapMemoryOptionToMemoryProviders(config.memory, config.projectName), identityProviders, + gatewayProviders, + gatewayAuthTypes: [...new Set(gatewayProviders.map(g => g.authType))], }; } diff --git a/src/cli/templates/render.ts b/src/cli/templates/render.ts index 166c90a3..e56a8490 100644 --- a/src/cli/templates/render.ts +++ b/src/cli/templates/render.ts @@ -8,6 +8,9 @@ Handlebars.registerHelper('includes', (array: unknown[], value: unknown) => { if (!Array.isArray(array)) return false; return array.includes(value); }); +Handlebars.registerHelper('snakeCase', (str: string) => { + return str.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase(); +}); /** * Renames template files to their actual names. diff --git a/src/cli/templates/types.ts b/src/cli/templates/types.ts index 37dded4e..dbde6651 100644 --- a/src/cli/templates/types.ts +++ b/src/cli/templates/types.ts @@ -18,6 +18,15 @@ export interface MemoryProviderRenderConfig { strategies: MemoryStrategyType[]; } +/** + * Gateway provider info for template rendering. + */ +export interface GatewayProviderRenderConfig { + name: string; + envVarName: string; + authType: string; // AWS_IAM, CUSTOM_JWT, NONE +} + /** * Configuration needed by template renderers. * This is separate from the v2 Agent schema which only stores runtime config. @@ -29,10 +38,15 @@ export interface AgentRenderConfig { modelProvider: ModelProvider; hasMemory: boolean; hasIdentity: boolean; + hasGateway: boolean; /** Build type: CodeZip (default) or Container */ buildType?: BuildType; /** Memory providers for template rendering */ memoryProviders: MemoryProviderRenderConfig[]; /** Identity providers for template rendering (maps to credentials in schema) */ identityProviders: IdentityProviderRenderConfig[]; + /** Gateway providers for template rendering */ + gatewayProviders: GatewayProviderRenderConfig[]; + /** Unique auth types across all gateways (for conditional imports) */ + gatewayAuthTypes: string[]; } diff --git a/src/cli/tui/screens/agent/useAddAgent.ts b/src/cli/tui/screens/agent/useAddAgent.ts index c5830385..cf6b89a6 100644 --- a/src/cli/tui/screens/agent/useAddAgent.ts +++ b/src/cli/tui/screens/agent/useAddAgent.ts @@ -170,7 +170,7 @@ async function handleCreatePath( } // Generate agent files with correct identity provider - const renderConfig = mapGenerateConfigToRenderConfig(generateConfig, identityProviders); + const renderConfig = await mapGenerateConfigToRenderConfig(generateConfig, identityProviders); const renderer = createRenderer(renderConfig); await renderer.render({ outputDir: projectRoot }); diff --git a/src/cli/tui/screens/create/useCreateFlow.ts b/src/cli/tui/screens/create/useCreateFlow.ts index 324b9e3c..079b8fc6 100644 --- a/src/cli/tui/screens/create/useCreateFlow.ts +++ b/src/cli/tui/screens/create/useCreateFlow.ts @@ -301,7 +301,7 @@ export function useCreateFlow(cwd: string): CreateFlowState { } // Render with correct identity provider - const renderConfig = mapGenerateConfigToRenderConfig(generateConfig, identityProviders); + const renderConfig = await mapGenerateConfigToRenderConfig(generateConfig, identityProviders); const renderer = createRenderer(renderConfig); logger.logSubStep('Rendering agent template...'); await renderer.render({ outputDir: projectRoot }); diff --git a/src/cli/tui/screens/generate/useGenerateFlow.ts b/src/cli/tui/screens/generate/useGenerateFlow.ts index 3c44f9d4..60a39eab 100644 --- a/src/cli/tui/screens/generate/useGenerateFlow.ts +++ b/src/cli/tui/screens/generate/useGenerateFlow.ts @@ -74,7 +74,7 @@ export function useGenerateFlow(): GenerateFlowState { // Build identity providers for template rendering const identityProviders = mapModelProviderToIdentityProviders(config.modelProvider, projectSpec.name); - const renderConfig = mapGenerateConfigToRenderConfig(config, identityProviders); + const renderConfig = await mapGenerateConfigToRenderConfig(config, identityProviders); const renderer = createRenderer(renderConfig); await renderer.render({ outputDir: project.projectRoot }); await writeAgentToProject(config); From 5e543e255e61554d81385f510a38950086c8195e Mon Sep 17 00:00:00 2001 From: Aidan Daly <99039782+aidandaly24@users.noreply.github.com> Date: Thu, 26 Feb 2026 09:03:54 -0500 Subject: [PATCH 16/28] refactor: remove dead agent binding step from add gateway wizard (#456) --- src/cli/commands/add/actions.ts | 8 ---- src/cli/commands/add/command.tsx | 1 - src/cli/tui/screens/add/AddFlow.tsx | 1 - src/cli/tui/screens/mcp/AddGatewayFlow.tsx | 4 -- src/cli/tui/screens/mcp/AddGatewayScreen.tsx | 47 +++---------------- src/cli/tui/screens/mcp/types.ts | 5 +- .../tui/screens/mcp/useAddGatewayWizard.ts | 19 +------- 7 files changed, 10 insertions(+), 75 deletions(-) diff --git a/src/cli/commands/add/actions.ts b/src/cli/commands/add/actions.ts index 5394a685..6d28bfc7 100644 --- a/src/cli/commands/add/actions.ts +++ b/src/cli/commands/add/actions.ts @@ -243,17 +243,9 @@ async function handleByoPath( // Gateway handler function buildGatewayConfig(options: ValidatedAddGatewayOptions): AddGatewayConfig { - const agents = options.agents - ? options.agents - .split(',') - .map(s => s.trim()) - .filter(Boolean) - : []; - const config: AddGatewayConfig = { name: options.name, description: options.description ?? `Gateway for ${options.name}`, - agents, authorizerType: options.authorizerType, jwtConfig: undefined, }; diff --git a/src/cli/commands/add/command.tsx b/src/cli/commands/add/command.tsx index df1dd80e..2db2b40a 100644 --- a/src/cli/commands/add/command.tsx +++ b/src/cli/commands/add/command.tsx @@ -272,7 +272,6 @@ export function registerAdd(program: Command) { .option('--discovery-url ', 'OIDC discovery URL (required for CUSTOM_JWT)') .option('--allowed-audience ', 'Comma-separated allowed audience values (required for CUSTOM_JWT)') .option('--allowed-clients ', 'Comma-separated allowed client IDs (required for CUSTOM_JWT)') - .option('--agents ', 'Comma-separated agent names to attach gateway to') .option('--json', 'Output as JSON') .action(async options => { requireProject(); diff --git a/src/cli/tui/screens/add/AddFlow.tsx b/src/cli/tui/screens/add/AddFlow.tsx index 0f2b4fab..dab5eb13 100644 --- a/src/cli/tui/screens/add/AddFlow.tsx +++ b/src/cli/tui/screens/add/AddFlow.tsx @@ -325,7 +325,6 @@ export function AddFlow(props: AddFlowProps) { return ( setFlow({ name: 'select' })} onDev={props.onDev} diff --git a/src/cli/tui/screens/mcp/AddGatewayFlow.tsx b/src/cli/tui/screens/mcp/AddGatewayFlow.tsx index 8e0529c3..c863c421 100644 --- a/src/cli/tui/screens/mcp/AddGatewayFlow.tsx +++ b/src/cli/tui/screens/mcp/AddGatewayFlow.tsx @@ -13,8 +13,6 @@ type FlowState = interface AddGatewayFlowProps { /** Whether running in interactive TUI mode */ isInteractive?: boolean; - /** Available agents for the create wizard */ - availableAgents: string[]; onExit: () => void; onBack: () => void; /** Called when user selects dev from success screen to run agent locally */ @@ -25,7 +23,6 @@ interface AddGatewayFlowProps { export function AddGatewayFlow({ isInteractive = true, - availableAgents, onExit, onBack, onDev, @@ -69,7 +66,6 @@ export function AddGatewayFlow({ return ( void; onExit: () => void; existingGateways: string[]; - availableAgents: string[]; unassignedTargets: string[]; } @@ -31,7 +30,6 @@ export function AddGatewayScreen({ onComplete, onExit, existingGateways, - availableAgents, unassignedTargets, }: AddGatewayScreenProps) { const wizard = useAddGatewayWizard(unassignedTargets.length); @@ -41,11 +39,6 @@ export function AddGatewayScreen({ const [jwtDiscoveryUrl, setJwtDiscoveryUrl] = useState(''); const [jwtAudience, setJwtAudience] = useState(''); - const agentItems: SelectableItem[] = useMemo( - () => availableAgents.map(name => ({ id: name, title: name })), - [availableAgents] - ); - const unassignedTargetItems: SelectableItem[] = useMemo( () => unassignedTargets.map(name => ({ id: name, title: name })), [unassignedTargets] @@ -59,7 +52,6 @@ export function AddGatewayScreen({ const isNameStep = wizard.step === 'name'; const isAuthorizerStep = wizard.step === 'authorizer'; const isJwtConfigStep = wizard.step === 'jwt-config'; - const isAgentsStep = wizard.step === 'agents'; const isIncludeTargetsStep = wizard.step === 'include-targets'; const isConfirmStep = wizard.step === 'confirm'; @@ -70,15 +62,6 @@ export function AddGatewayScreen({ isActive: isAuthorizerStep, }); - const agentsNav = useMultiSelectNavigation({ - items: agentItems, - getId: item => item.id, - onConfirm: ids => wizard.setAgents(ids), - onExit: () => wizard.goBack(), - isActive: isAgentsStep, - requireSelection: false, - }); - const targetsNav = useMultiSelectNavigation({ items: unassignedTargetItems, getId: item => item.id, @@ -135,14 +118,13 @@ export function AddGatewayScreen({ } }; - const helpText = - isAgentsStep || isIncludeTargetsStep - ? 'Space toggle · Enter confirm · Esc back' - : isConfirmStep - ? HELP_TEXT.CONFIRM_CANCEL - : isAuthorizerStep - ? HELP_TEXT.NAVIGATE_SELECT - : HELP_TEXT.TEXT_INPUT; + const helpText = isIncludeTargetsStep + ? 'Space toggle · Enter confirm · Esc back' + : isConfirmStep + ? HELP_TEXT.CONFIRM_CANCEL + : isAuthorizerStep + ? HELP_TEXT.NAVIGATE_SELECT + : HELP_TEXT.TEXT_INPUT; const headerContent = ; @@ -187,20 +169,6 @@ export function AddGatewayScreen({ /> )} - {isAgentsStep && - (agentItems.length > 0 ? ( - - ) : ( - - No agents defined. Add agents first via `agentcore add agent`. Press Enter to continue. - - ))} - {isIncludeTargetsStep && (unassignedTargetItems.length > 0 ? ( 0 ? wizard.config.agents.join(', ') : '(none)' }, { label: 'Targets', value: diff --git a/src/cli/tui/screens/mcp/types.ts b/src/cli/tui/screens/mcp/types.ts index e6151154..8cee2c4d 100644 --- a/src/cli/tui/screens/mcp/types.ts +++ b/src/cli/tui/screens/mcp/types.ts @@ -4,13 +4,11 @@ import type { GatewayAuthorizerType, NodeRuntime, PythonRuntime, ToolDefinition // Gateway Flow Types // ───────────────────────────────────────────────────────────────────────────── -export type AddGatewayStep = 'name' | 'authorizer' | 'jwt-config' | 'agents' | 'include-targets' | 'confirm'; +export type AddGatewayStep = 'name' | 'authorizer' | 'jwt-config' | 'include-targets' | 'confirm'; export interface AddGatewayConfig { name: string; description: string; - /** Agent names that will use this gateway */ - agents: string[]; /** Authorization type for the gateway */ authorizerType: GatewayAuthorizerType; /** JWT authorizer configuration (when authorizerType is 'CUSTOM_JWT') */ @@ -27,7 +25,6 @@ export const GATEWAY_STEP_LABELS: Record = { name: 'Name', authorizer: 'Authorizer', 'jwt-config': 'JWT Config', - agents: 'Agents', 'include-targets': 'Include Targets', confirm: 'Confirm', }; diff --git a/src/cli/tui/screens/mcp/useAddGatewayWizard.ts b/src/cli/tui/screens/mcp/useAddGatewayWizard.ts index 7600b49c..2bd24b75 100644 --- a/src/cli/tui/screens/mcp/useAddGatewayWizard.ts +++ b/src/cli/tui/screens/mcp/useAddGatewayWizard.ts @@ -4,8 +4,8 @@ import { useCallback, useMemo, useState } from 'react'; /** Maps authorizer type to the next step after authorizer selection */ const AUTHORIZER_NEXT_STEP: Record = { - NONE: 'agents', - AWS_IAM: 'agents', + NONE: 'confirm', + AWS_IAM: 'confirm', CUSTOM_JWT: 'jwt-config', }; @@ -13,7 +13,6 @@ function getDefaultConfig(): AddGatewayConfig { return { name: '', description: '', - agents: [], authorizerType: 'NONE', jwtConfig: undefined, selectedTargets: [], @@ -32,8 +31,6 @@ export function useAddGatewayWizard(unassignedTargetsCount = 0) { baseSteps.push('jwt-config'); } - baseSteps.push('agents'); - if (unassignedTargetsCount > 0) { baseSteps.push('include-targets'); } @@ -76,17 +73,6 @@ export function useAddGatewayWizard(unassignedTargetsCount = 0) { ...c, jwtConfig, })); - setStep('agents'); - }, - [] - ); - - const setAgents = useCallback( - (agents: string[]) => { - setConfig(c => ({ - ...c, - agents, - })); setStep(unassignedTargetsCount > 0 ? 'include-targets' : 'confirm'); }, [unassignedTargetsCount] @@ -114,7 +100,6 @@ export function useAddGatewayWizard(unassignedTargetsCount = 0) { setName, setAuthorizerType, setJwtConfig, - setAgents, setSelectedTargets, reset, }; From 3f17d35022a82b3ad0a6a00129acf8ce705585a8 Mon Sep 17 00:00:00 2001 From: Aidan Daly <99039782+aidandaly24@users.noreply.github.com> Date: Thu, 26 Feb 2026 09:56:19 -0500 Subject: [PATCH 17/28] feat: wire existing-endpoint flow for gateway targets (#455) * feat: wire existing-endpoint flow for gateway targets * test: add routing and validation tests for existing-endpoint flow * refactor: remove source/language/host steps, existing-endpoint is the only flow --- .../commands/add/__tests__/actions.test.ts | 34 ++++++++- .../commands/add/__tests__/validate.test.ts | 12 ++++ src/cli/commands/add/actions.ts | 10 ++- src/cli/commands/add/validate.ts | 3 + .../screens/mcp/AddGatewayTargetScreen.tsx | 71 +------------------ .../screens/mcp/useAddGatewayTargetWizard.ts | 62 +++------------- 6 files changed, 70 insertions(+), 122 deletions(-) diff --git a/src/cli/commands/add/__tests__/actions.test.ts b/src/cli/commands/add/__tests__/actions.test.ts index 852523bc..0fffde89 100644 --- a/src/cli/commands/add/__tests__/actions.test.ts +++ b/src/cli/commands/add/__tests__/actions.test.ts @@ -1,6 +1,15 @@ import { buildGatewayTargetConfig } from '../actions.js'; import type { ValidatedAddGatewayTargetOptions } from '../actions.js'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const mockCreateToolFromWizard = vi.fn().mockResolvedValue({ toolName: 'test', projectPath: '/tmp' }); +const mockCreateExternalGatewayTarget = vi.fn().mockResolvedValue({ toolName: 'test', projectPath: '' }); + +vi.mock('../../../operations/mcp/create-mcp', () => ({ + createToolFromWizard: (...args: unknown[]) => mockCreateToolFromWizard(...args), + createExternalGatewayTarget: (...args: unknown[]) => mockCreateExternalGatewayTarget(...args), + createGatewayFromWizard: vi.fn(), +})); describe('buildGatewayTargetConfig', () => { it('maps name, gateway, language correctly', () => { @@ -66,3 +75,26 @@ describe('buildGatewayTargetConfig', () => { expect(config.outboundAuth).toBeUndefined(); }); }); + +// Dynamic import to pick up mocks +const { handleAddGatewayTarget } = await import('../actions.js'); + +describe('handleAddGatewayTarget', () => { + afterEach(() => vi.clearAllMocks()); + + it('routes existing-endpoint to createExternalGatewayTarget', async () => { + const options: ValidatedAddGatewayTargetOptions = { + name: 'test-tool', + language: 'Other', + host: 'Lambda', + source: 'existing-endpoint', + endpoint: 'https://example.com/mcp', + gateway: 'my-gw', + }; + + await handleAddGatewayTarget(options); + + expect(mockCreateExternalGatewayTarget).toHaveBeenCalledOnce(); + expect(mockCreateToolFromWizard).not.toHaveBeenCalled(); + }); +}); diff --git a/src/cli/commands/add/__tests__/validate.test.ts b/src/cli/commands/add/__tests__/validate.test.ts index e9a7992a..40f5dfec 100644 --- a/src/cli/commands/add/__tests__/validate.test.ts +++ b/src/cli/commands/add/__tests__/validate.test.ts @@ -422,6 +422,18 @@ describe('validate', () => { expect(result.valid).toBe(false); expect(result.error).toContain('--credential-name is required'); }); + + it('rejects --host with existing-endpoint', async () => { + const options: AddGatewayTargetOptions = { + name: 'test-tool', + source: 'existing-endpoint', + endpoint: 'https://example.com/mcp', + host: 'Lambda', + }; + const result = await validateAddGatewayTargetOptions(options); + expect(result.valid).toBe(false); + expect(result.error).toBe('--host is not applicable for existing endpoint targets'); + }); }); describe('validateAddMemoryOptions', () => { diff --git a/src/cli/commands/add/actions.ts b/src/cli/commands/add/actions.ts index 6d28bfc7..0e2543f9 100644 --- a/src/cli/commands/add/actions.ts +++ b/src/cli/commands/add/actions.ts @@ -23,7 +23,11 @@ import { createCredential, resolveCredentialStrategy, } from '../../operations/identity/create-identity'; -import { createGatewayFromWizard, createToolFromWizard } from '../../operations/mcp/create-mcp'; +import { + createExternalGatewayTarget, + createGatewayFromWizard, + createToolFromWizard, +} from '../../operations/mcp/create-mcp'; import { createMemory } from '../../operations/memory/create-memory'; import { createRenderer } from '../../templates'; import type { MemoryOption } from '../../tui/screens/generate/types'; @@ -334,6 +338,10 @@ export async function handleAddGatewayTarget( } const config = buildGatewayTargetConfig(options); + if (config.source === 'existing-endpoint') { + const result = await createExternalGatewayTarget(config); + return { success: true, toolName: result.toolName }; + } const result = await createToolFromWizard(config); return { success: true, toolName: result.toolName, sourcePath: result.projectPath }; } catch (err) { diff --git a/src/cli/commands/add/validate.ts b/src/cli/commands/add/validate.ts index d7cbc802..2731ed83 100644 --- a/src/cli/commands/add/validate.ts +++ b/src/cli/commands/add/validate.ts @@ -198,6 +198,9 @@ export async function validateAddGatewayTargetOptions(options: AddGatewayTargetO } if (options.source === 'existing-endpoint') { + if (options.host) { + return { valid: false, error: '--host is not applicable for existing endpoint targets' }; + } if (!options.endpoint) { return { valid: false, error: '--endpoint is required when source is existing-endpoint' }; } diff --git a/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx b/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx index f8a2522e..0c811d43 100644 --- a/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx +++ b/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx @@ -4,14 +4,8 @@ import type { SelectableItem } from '../../components'; import { HELP_TEXT } from '../../constants'; import { useListNavigation } from '../../hooks'; import { generateUniqueName } from '../../utils'; -import type { AddGatewayTargetConfig, ComputeHost, TargetLanguage } from './types'; -import { - COMPUTE_HOST_OPTIONS, - MCP_TOOL_STEP_LABELS, - SKIP_FOR_NOW, - SOURCE_OPTIONS, - TARGET_LANGUAGE_OPTIONS, -} from './types'; +import type { AddGatewayTargetConfig } from './types'; +import { MCP_TOOL_STEP_LABELS, SKIP_FOR_NOW } from './types'; import { useAddGatewayTargetWizard } from './useAddGatewayTargetWizard'; import { Box, Text } from 'ink'; import React, { useMemo } from 'react'; @@ -31,16 +25,6 @@ export function AddGatewayTargetScreen({ }: AddGatewayTargetScreenProps) { const wizard = useAddGatewayTargetWizard(existingGateways); - const sourceItems: SelectableItem[] = useMemo( - () => SOURCE_OPTIONS.map(o => ({ id: o.id, title: o.title, description: o.description })), - [] - ); - - const languageItems: SelectableItem[] = useMemo( - () => TARGET_LANGUAGE_OPTIONS.map(o => ({ id: o.id, title: o.title, description: o.description })), - [] - ); - const gatewayItems: SelectableItem[] = useMemo( () => [ ...existingGateways.map(g => ({ id: g, title: g })), @@ -49,33 +33,11 @@ export function AddGatewayTargetScreen({ [existingGateways] ); - const hostItems: SelectableItem[] = useMemo( - () => COMPUTE_HOST_OPTIONS.map(o => ({ id: o.id, title: o.title, description: o.description })), - [] - ); - - const isSourceStep = wizard.step === 'source'; - const isLanguageStep = wizard.step === 'language'; const isGatewayStep = wizard.step === 'gateway'; - const isHostStep = wizard.step === 'host'; const isTextStep = wizard.step === 'name' || wizard.step === 'endpoint'; const isConfirmStep = wizard.step === 'confirm'; const noGatewaysAvailable = isGatewayStep && existingGateways.length === 0; - const sourceNav = useListNavigation({ - items: sourceItems, - onSelect: item => wizard.setSource(item.id as 'existing-endpoint' | 'create-new'), - onExit: () => wizard.goBack(), - isActive: isSourceStep, - }); - - const languageNav = useListNavigation({ - items: languageItems, - onSelect: item => wizard.setLanguage(item.id as TargetLanguage), - onExit: () => onExit(), - isActive: isLanguageStep, - }); - const gatewayNav = useListNavigation({ items: gatewayItems, onSelect: item => wizard.setGateway(item.id), @@ -83,13 +45,6 @@ export function AddGatewayTargetScreen({ isActive: isGatewayStep && !noGatewaysAvailable, }); - const hostNav = useListNavigation({ - items: hostItems, - onSelect: item => wizard.setHost(item.id as ComputeHost), - onExit: () => wizard.goBack(), - isActive: isHostStep, - }); - useListNavigation({ items: [{ id: 'confirm', title: 'Confirm' }], onSelect: () => onComplete(wizard.config), @@ -108,19 +63,6 @@ export function AddGatewayTargetScreen({ return ( - {isSourceStep && ( - - )} - - {isLanguageStep && ( - - )} - {isGatewayStep && !noGatewaysAvailable && ( } - {isHostStep && ( - - )} - {isTextStep && ( (getDefaultConfig); const [step, setStep] = useState('name'); - const steps = useMemo(() => getSteps(config.source), [config.source]); + const steps = useMemo(() => getSteps(), []); const currentIndex = steps.indexOf(step); const goBack = useCallback(() => { - // Recalculate steps in case source changed - const currentSteps = getSteps(config.source); + const currentSteps = getSteps(); const idx = currentSteps.indexOf(step); const prevStep = currentSteps[idx - 1]; if (prevStep) setStep(prevStep); - }, [config.source, step]); + }, [step]); const setName = useCallback((name: string) => { setConfig(c => ({ @@ -58,19 +54,7 @@ export function useAddGatewayTargetWizard(existingGateways: string[] = []) { sourcePath: `${APP_DIR}/${MCP_APP_SUBDIR}/${name}`, toolDefinition: deriveToolDefinition(name), })); - setStep('source'); - }, []); - - const setSource = useCallback((source: 'existing-endpoint' | 'create-new') => { - setConfig(c => ({ - ...c, - source, - })); - if (source === 'existing-endpoint') { - setStep('endpoint'); - } else { - setStep('language'); - } + setStep('endpoint'); }, []); const setEndpoint = useCallback((endpoint: string) => { @@ -81,35 +65,14 @@ export function useAddGatewayTargetWizard(existingGateways: string[] = []) { setStep('gateway'); }, []); - const setLanguage = useCallback((language: TargetLanguage) => { - setConfig(c => ({ - ...c, - language, - })); - setStep('gateway'); - }, []); - const setGateway = useCallback((gateway: string) => { setConfig(c => { - const isExternal = c.source === 'existing-endpoint'; const isSkipped = gateway === SKIP_FOR_NOW; - if (isExternal || isSkipped) { - setStep('confirm'); - } else { - setStep('host'); - } + setStep('confirm'); return { ...c, gateway: isSkipped ? undefined : gateway }; }); }, []); - const setHost = useCallback((host: ComputeHost) => { - setConfig(c => ({ - ...c, - host, - })); - setStep('confirm'); - }, []); - const reset = useCallback(() => { setConfig(getDefaultConfig()); setStep('name'); @@ -123,11 +86,8 @@ export function useAddGatewayTargetWizard(existingGateways: string[] = []) { existingGateways, goBack, setName, - setSource, setEndpoint, - setLanguage, setGateway, - setHost, reset, }; } From f559be59c7597120fa701b108dc8d83796f2bc80 Mon Sep 17 00:00:00 2001 From: Aidan Daly <99039782+aidandaly24@users.noreply.github.com> Date: Thu, 26 Feb 2026 10:32:34 -0500 Subject: [PATCH 18/28] fix: CDK template types and credential ARN passthrough for gateway deploy (#432) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: correct CDK template type names and prop names The CDK stack template used McpSpec (doesn't exist) instead of AgentCoreMcpSpec, and passed wrong prop names to AgentCoreMcp: - spec → mcpSpec - application → agentCoreApplication - Added missing projectName prop * fix: collect API key credential ARNs and write to deployed state before CDK synth API key credential providers were created during deploy but their ARNs were not stored in deployed state, causing CDK to fail with 'Credential not found in deployed state' for gateway targets with API key auth. - Return credentialProviderArn from create/update API key providers - Unify API key and OAuth credential ARNs into single deployed state map - Move credential setup before CDK synth so template can read ARNs - Write partial deployed state with credentials before synth * fix: pass credential ARNs from deployed state to CDK gateway construct CDK template now reads deployed-state.json and extracts credential provider ARNs per target, passing them to AgentCoreMcp so gateway targets can reference outbound auth credentials. * fix: reorder TUI preflight to create credentials before CDK synth * fix: fetch OAuth credential ARN via Get after create/update * fix: handle Mcp prefix in gateway output key parsing * fix: bump CDK version to 2.239.0 in project template * fix: lint errors in deploy actions and preflight hook --- package.json | 2 +- .../assets.snapshot.test.ts.snap | 38 ++-- src/assets/cdk/bin/cdk.ts | 18 +- src/assets/cdk/lib/cdk-stack.ts | 18 +- src/assets/cdk/package.json | 2 +- src/cli/cloudformation/outputs.ts | 4 +- src/cli/commands/deploy/actions.ts | 167 ++++++++++-------- .../operations/deploy/pre-deploy-identity.ts | 3 + .../identity/api-key-credential-provider.ts | 19 +- .../identity/oauth2-credential-provider.ts | 13 +- src/cli/tui/hooks/useCdkPreflight.ts | 84 +++++++-- 11 files changed, 243 insertions(+), 125 deletions(-) diff --git a/package.json b/package.json index 2a80b07a..4a3aafa5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@aws/agentcore", - "version": "0.3.0-preview.2.1", + "version": "0.3.0-preview.3", "description": "CLI for Amazon Bedrock AgentCore", "license": "Apache-2.0", "repository": { diff --git a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap index d9fefab2..c2d3adbe 100644 --- a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap +++ b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap @@ -75,15 +75,20 @@ async function main() { // Read MCP configuration if it exists let mcpSpec; - let mcpDeployedState; try { mcpSpec = await configIO.readMcpSpec(); - const deployedState = JSON.parse(fs.readFileSync(path.join(configRoot, '.cli', 'deployed-state.json'), 'utf8')); - mcpDeployedState = deployedState?.mcp; } catch { // MCP config is optional } + // Read deployed state for credential ARNs (populated by pre-deploy identity setup) + let deployedState: Record | undefined; + try { + deployedState = JSON.parse(fs.readFileSync(path.join(configRoot, '.cli', 'deployed-state.json'), 'utf8')); + } catch { + // Deployed state may not exist on first deploy + } + if (targets.length === 0) { throw new Error('No deployment targets configured. Please define targets in agentcore/aws-targets.json'); } @@ -94,10 +99,15 @@ async function main() { const env = toEnvironment(target); const stackName = toStackName(spec.name, target.name); + // Extract credentials from deployed state for this target + const targetState = (deployedState as Record)?.targets as Record> | undefined; + const targetResources = targetState?.[target.name]?.resources as Record | undefined; + const credentials = targetResources?.credentials as Record | undefined; + new AgentCoreStack(app, stackName, { spec, mcpSpec, - mcpDeployedState, + credentials, env, description: \`AgentCore stack for \${spec.name} deployed to \${target.name} (\${target.region})\`, tags: { @@ -240,8 +250,7 @@ exports[`Assets Directory Snapshots > CDK assets > cdk/cdk/lib/cdk-stack.ts shou AgentCoreApplication, AgentCoreMcp, type AgentCoreProjectSpec, - type McpSpec, - type McpDeployedState, + type AgentCoreMcpSpec, } from '@aws/agentcore-cdk'; import { CfnOutput, Stack, type StackProps } from 'aws-cdk-lib'; import { Construct } from 'constructs'; @@ -254,11 +263,11 @@ export interface AgentCoreStackProps extends StackProps { /** * The MCP specification containing gateways and servers. */ - mcpSpec?: McpSpec; + mcpSpec?: AgentCoreMcpSpec; /** - * The MCP deployed state. + * Credential provider ARNs from deployed state, keyed by credential name. */ - mcpDeployedState?: McpDeployedState; + credentials?: Record; } /** @@ -274,7 +283,7 @@ export class AgentCoreStack extends Stack { constructor(scope: Construct, id: string, props: AgentCoreStackProps) { super(scope, id, props); - const { spec, mcpSpec, mcpDeployedState } = props; + const { spec, mcpSpec, credentials } = props; // Create AgentCoreApplication with all agents this.application = new AgentCoreApplication(this, 'Application', { @@ -284,9 +293,10 @@ export class AgentCoreStack extends Stack { // Create AgentCoreMcp if there are gateways configured if (mcpSpec?.agentCoreGateways && mcpSpec.agentCoreGateways.length > 0) { new AgentCoreMcp(this, 'Mcp', { - spec: mcpSpec, - deployedState: mcpDeployedState, - application: this.application, + projectName: spec.name, + mcpSpec, + agentCoreApplication: this.application, + credentials, }); } @@ -337,7 +347,7 @@ exports[`Assets Directory Snapshots > CDK assets > cdk/cdk/package.json should m }, "dependencies": { "@aws/agentcore-cdk": "^0.1.0-alpha.1", - "aws-cdk-lib": "2.234.1", + "aws-cdk-lib": "2.239.0", "constructs": "^10.0.0" } } diff --git a/src/assets/cdk/bin/cdk.ts b/src/assets/cdk/bin/cdk.ts index 3c62245f..ef16d28d 100644 --- a/src/assets/cdk/bin/cdk.ts +++ b/src/assets/cdk/bin/cdk.ts @@ -30,15 +30,20 @@ async function main() { // Read MCP configuration if it exists let mcpSpec; - let mcpDeployedState; try { mcpSpec = await configIO.readMcpSpec(); - const deployedState = JSON.parse(fs.readFileSync(path.join(configRoot, '.cli', 'deployed-state.json'), 'utf8')); - mcpDeployedState = deployedState?.mcp; } catch { // MCP config is optional } + // Read deployed state for credential ARNs (populated by pre-deploy identity setup) + let deployedState: Record | undefined; + try { + deployedState = JSON.parse(fs.readFileSync(path.join(configRoot, '.cli', 'deployed-state.json'), 'utf8')); + } catch { + // Deployed state may not exist on first deploy + } + if (targets.length === 0) { throw new Error('No deployment targets configured. Please define targets in agentcore/aws-targets.json'); } @@ -49,10 +54,15 @@ async function main() { const env = toEnvironment(target); const stackName = toStackName(spec.name, target.name); + // Extract credentials from deployed state for this target + const targetState = (deployedState as Record)?.targets as Record> | undefined; + const targetResources = targetState?.[target.name]?.resources as Record | undefined; + const credentials = targetResources?.credentials as Record | undefined; + new AgentCoreStack(app, stackName, { spec, mcpSpec, - mcpDeployedState, + credentials, env, description: `AgentCore stack for ${spec.name} deployed to ${target.name} (${target.region})`, tags: { diff --git a/src/assets/cdk/lib/cdk-stack.ts b/src/assets/cdk/lib/cdk-stack.ts index fbff1465..ecbf15b8 100644 --- a/src/assets/cdk/lib/cdk-stack.ts +++ b/src/assets/cdk/lib/cdk-stack.ts @@ -2,8 +2,7 @@ import { AgentCoreApplication, AgentCoreMcp, type AgentCoreProjectSpec, - type McpSpec, - type McpDeployedState, + type AgentCoreMcpSpec, } from '@aws/agentcore-cdk'; import { CfnOutput, Stack, type StackProps } from 'aws-cdk-lib'; import { Construct } from 'constructs'; @@ -16,11 +15,11 @@ export interface AgentCoreStackProps extends StackProps { /** * The MCP specification containing gateways and servers. */ - mcpSpec?: McpSpec; + mcpSpec?: AgentCoreMcpSpec; /** - * The MCP deployed state. + * Credential provider ARNs from deployed state, keyed by credential name. */ - mcpDeployedState?: McpDeployedState; + credentials?: Record; } /** @@ -36,7 +35,7 @@ export class AgentCoreStack extends Stack { constructor(scope: Construct, id: string, props: AgentCoreStackProps) { super(scope, id, props); - const { spec, mcpSpec, mcpDeployedState } = props; + const { spec, mcpSpec, credentials } = props; // Create AgentCoreApplication with all agents this.application = new AgentCoreApplication(this, 'Application', { @@ -46,9 +45,10 @@ export class AgentCoreStack extends Stack { // Create AgentCoreMcp if there are gateways configured if (mcpSpec?.agentCoreGateways && mcpSpec.agentCoreGateways.length > 0) { new AgentCoreMcp(this, 'Mcp', { - spec: mcpSpec, - deployedState: mcpDeployedState, - application: this.application, + projectName: spec.name, + mcpSpec, + agentCoreApplication: this.application, + credentials, }); } diff --git a/src/assets/cdk/package.json b/src/assets/cdk/package.json index 77e21bd0..eb09002e 100644 --- a/src/assets/cdk/package.json +++ b/src/assets/cdk/package.json @@ -24,7 +24,7 @@ }, "dependencies": { "@aws/agentcore-cdk": "^0.1.0-alpha.1", - "aws-cdk-lib": "2.234.1", + "aws-cdk-lib": "2.239.0", "constructs": "^10.0.0" } } diff --git a/src/cli/cloudformation/outputs.ts b/src/cli/cloudformation/outputs.ts index 7e053e09..9cf5ae77 100644 --- a/src/cli/cloudformation/outputs.ts +++ b/src/cli/cloudformation/outputs.ts @@ -45,8 +45,8 @@ export function parseGatewayOutputs( const gatewayNames = Object.keys(gatewaySpecs); const gatewayIdMap = new Map(gatewayNames.map(name => [toPascalId(name), name])); - // Match pattern: Gateway{GatewayName}UrlOutput - const outputPattern = /^Gateway(.+?)UrlOutput/; + // Match patterns: Gateway{Name}{Type}Output or McpGateway{Name}{Type}Output + const outputPattern = /^(?:Mcp)?Gateway(.+?)(Id|Arn|Url)Output/; for (const [key, value] of Object.entries(outputs)) { const match = outputPattern.exec(key); diff --git a/src/cli/commands/deploy/actions.ts b/src/cli/commands/deploy/actions.ts index bc6b14ce..70cd4bcc 100644 --- a/src/cli/commands/deploy/actions.ts +++ b/src/cli/commands/deploy/actions.ts @@ -20,6 +20,7 @@ import { } from '../../operations/deploy'; import { formatTargetStatus, getGatewayTargetStatuses } from '../../operations/deploy/gateway-status'; import type { DeployResult } from './types'; +import type { DeployedState } from '../../../schema'; export interface ValidatedDeployOptions { target: string; @@ -104,70 +105,7 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise 0 ? new SecureCredentials(envCredentials) : undefined; + // Unified credentials map for deployed state (both API Key and OAuth) + const deployedCredentials: Record< + string, + { credentialProviderArn: string; clientSecretArn?: string; callbackUrl?: string } + > = {}; + if (hasIdentityApiProviders(context.projectSpec)) { startStep('Creating credentials...'); @@ -201,14 +145,19 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise = {}; if (hasIdentityOAuthProviders(context.projectSpec)) { startStep('Creating OAuth credentials...'); @@ -226,10 +175,10 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise 0) { + const existingPreSynthState = await configIO.readDeployedState().catch(() => ({targets: {}} as DeployedState)); + const targetState = existingPreSynthState.targets?.[target.name] ?? { resources: {} }; + targetState.resources ??= {}; + targetState.resources.credentials = deployedCredentials; + if (identityKmsKeyArn) targetState.resources.identityKmsKeyArn = identityKmsKeyArn; + await configIO.writeDeployedState({ + ...existingPreSynthState, + targets: { ...existingPreSynthState.targets, [target.name]: targetState }, + }); + } + + // Synthesize CloudFormation templates + startStep('Synthesize CloudFormation'); + const switchableIoHost = options.verbose ? createSwitchableIoHost() : undefined; + const synthResult = await synthesizeCdk( + context.cdkProject, + switchableIoHost ? { ioHost: switchableIoHost.ioHost } : undefined + ); + toolkitWrapper = synthResult.toolkitWrapper; + const stackNames = synthResult.stackNames; + if (stackNames.length === 0) { + endStep('error', 'No stacks found'); + logger.finalize(false); + return { success: false, error: 'No stacks found to deploy', logPath: logger.getRelativeLogPath() }; + } + const stackName = stackNames[0]!; + endStep('success'); + + // Check if bootstrap needed + startStep('Check bootstrap status'); + const bootstrapCheck = await checkBootstrapNeeded(context.awsTargets); + if (bootstrapCheck.needsBootstrap) { + if (options.autoConfirm) { + logger.log('Bootstrap needed, auto-confirming...'); + await bootstrapEnvironment(toolkitWrapper, target); + } else { + endStep('error', 'Bootstrap required'); + logger.finalize(false); + return { + success: false, + error: 'AWS environment needs bootstrapping. Run with --yes to auto-bootstrap.', + logPath: logger.getRelativeLogPath(), + }; + } + } + endStep('success'); + + // Check stack deployability + startStep('Check stack status'); + const deployabilityCheck = await checkStackDeployability(target.region, stackNames); + if (!deployabilityCheck.canDeploy) { + endStep('error', deployabilityCheck.message); + logger.finalize(false); + return { + success: false, + error: deployabilityCheck.message ?? 'Stack is not in a deployable state', + logPath: logger.getRelativeLogPath(), + }; + } + endStep('success'); + + // Plan mode: stop after synth and checks, don't deploy + if (options.plan) { + logger.finalize(true); + await toolkitWrapper.dispose(); + toolkitWrapper = null; + return { + success: true, + targetName: target.name, + stackName, + logPath: logger.getRelativeLogPath(), + }; + } + // Deploy const hasGateways = mcpSpec?.agentCoreGateways && mcpSpec.agentCoreGateways.length > 0; const deployStepName = hasGateways ? 'Deploying gateways...' : 'Deploy to AWS'; @@ -313,7 +338,7 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise { +): Promise<{ success: boolean; credentialProviderArn?: string; error?: string }> { try { await client.send( new CreateApiKeyCredentialProviderCommand({ @@ -48,11 +48,18 @@ export async function createApiKeyProvider( apiKey: apiKey, }) ); - return { success: true }; + // Create response doesn't include credentialProviderArn — fetch it + const getResponse = await client.send(new GetApiKeyCredentialProviderCommand({ name: providerName })); + return { success: true, credentialProviderArn: getResponse.credentialProviderArn }; } catch (error) { const errorName = (error as { name?: string }).name; if (errorName === 'ConflictException' || errorName === 'ResourceAlreadyExistsException') { - return { success: true }; + try { + const getResponse = await client.send(new GetApiKeyCredentialProviderCommand({ name: providerName })); + return { success: true, credentialProviderArn: getResponse.credentialProviderArn }; + } catch { + return { success: true }; + } } return { success: false, @@ -68,7 +75,7 @@ export async function updateApiKeyProvider( client: BedrockAgentCoreControlClient, providerName: string, apiKey: string -): Promise<{ success: boolean; error?: string }> { +): Promise<{ success: boolean; credentialProviderArn?: string; error?: string }> { try { await client.send( new UpdateApiKeyCredentialProviderCommand({ @@ -76,7 +83,9 @@ export async function updateApiKeyProvider( apiKey: apiKey, }) ); - return { success: true }; + // Update response doesn't include credentialProviderArn — fetch it + const getResponse = await client.send(new GetApiKeyCredentialProviderCommand({ name: providerName })); + return { success: true, credentialProviderArn: getResponse.credentialProviderArn }; } catch (error) { return { success: false, diff --git a/src/cli/operations/identity/oauth2-credential-provider.ts b/src/cli/operations/identity/oauth2-credential-provider.ts index e148d61f..cd037670 100644 --- a/src/cli/operations/identity/oauth2-credential-provider.ts +++ b/src/cli/operations/identity/oauth2-credential-provider.ts @@ -96,7 +96,12 @@ export async function createOAuth2Provider( ): Promise<{ success: boolean; result?: OAuth2ProviderResult; error?: string }> { try { const response = await client.send(new CreateOauth2CredentialProviderCommand(buildOAuth2Config(params))); - const result = extractResult(response); + let result = extractResult(response); + if (!result) { + // Create response may not include credentialProviderArn — fetch it + const getResult = await getOAuth2Provider(client, params.name); + result = getResult.result; + } if (!result) { return { success: false, error: 'No credential provider ARN in response' }; } @@ -146,7 +151,11 @@ export async function updateOAuth2Provider( ): Promise<{ success: boolean; result?: OAuth2ProviderResult; error?: string }> { try { const response = await client.send(new UpdateOauth2CredentialProviderCommand(buildOAuth2Config(params))); - const result = extractResult(response); + let result = extractResult(response); + if (!result) { + const getResult = await getOAuth2Provider(client, params.name); + result = getResult.result; + } if (!result) { return { success: false, error: 'No credential provider ARN in response' }; } diff --git a/src/cli/tui/hooks/useCdkPreflight.ts b/src/cli/tui/hooks/useCdkPreflight.ts index 785e60c9..9b95c742 100644 --- a/src/cli/tui/hooks/useCdkPreflight.ts +++ b/src/cli/tui/hooks/useCdkPreflight.ts @@ -1,4 +1,5 @@ -import { SecureCredentials } from '../../../lib'; +import { ConfigIO, SecureCredentials } from '../../../lib'; +import type { DeployedState } from '../../../schema'; import { AwsCredentialsError, validateAwsCredentials } from '../../aws/account'; import { type CdkToolkitWrapper, type SwitchableIoHost, createSwitchableIoHost } from '../../cdk/toolkit-lib'; import { getErrorMessage, isExpiredTokenError, isNoCredentialsError } from '../../errors'; @@ -361,6 +362,20 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { return; } + // Check if API key providers need setup before CDK synth (CDK needs credential ARNs) + // Skip this check if skipIdentityCheck is true (e.g., plan command only synthesizes) + const needsCredentialSetup = !skipIdentityCheck && (hasIdentityApiProviders(preflightContext.projectSpec) || hasIdentityOAuthProviders(preflightContext.projectSpec)); + if (needsCredentialSetup) { + // Get all credentials for the prompt (not just missing ones) + const allCredentials = getAllCredentials(preflightContext.projectSpec); + + // Always show dialog when credentials exist + setMissingCredentials(allCredentials); + setPhase('credentials-prompt'); + isRunningRef.current = false; // Reset so identity-setup can run after user input + return; + } + // Step: Synthesize CloudFormation updateStep(STEP_SYNTH, { status: 'running' }); logger.startStep('Synthesize CloudFormation'); @@ -422,20 +437,6 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { updateStep(STEP_STACK_STATUS, { status: 'success' }); } - // Check if API key providers need setup - always prompt user for credential source - // Skip this check if skipIdentityCheck is true (e.g., plan command only synthesizes) - const needsApiKeySetup = !skipIdentityCheck && hasIdentityApiProviders(preflightContext.projectSpec); - if (needsApiKeySetup) { - // Get all credentials for the prompt (not just missing ones) - const allCredentials = getAllCredentials(preflightContext.projectSpec); - - // Always show dialog when credentials exist - setMissingCredentials(allCredentials); - setPhase('credentials-prompt'); - isRunningRef.current = false; // Reset so identity-setup can run after user input - return; - } - // Check if bootstrap is needed const bootstrapCheck = await checkBootstrapNeeded(preflightContext.awsTargets); if (bootstrapCheck.needsBootstrap && bootstrapCheck.target) { @@ -566,6 +567,19 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { logger.endStep('success'); setSteps(prev => prev.map((s, i) => (i === prev.length - 1 ? { ...s, status: 'success' } : s))); + // Collect API Key credential ARNs for deployed state + const deployedCredentials: Record< + string, + { credentialProviderArn: string; clientSecretArn?: string; callbackUrl?: string } + > = {}; + for (const result of identityResult.results) { + if (result.credentialProviderArn) { + deployedCredentials[result.providerName] = { + credentialProviderArn: result.credentialProviderArn, + }; + } + } + // Set up OAuth credential providers if needed if (hasIdentityOAuthProviders(context.projectSpec)) { setSteps(prev => [...prev, { label: 'Set up OAuth providers', status: 'running' }]); @@ -617,19 +631,57 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { } } setOauthCredentials(creds); + Object.assign(deployedCredentials, creds); logger.endStep('success'); setSteps(prev => prev.map((s, i) => (i === prev.length - 1 ? { ...s, status: 'success' } : s))); } + // Write partial deployed state with credential ARNs before CDK synth + if (Object.keys(deployedCredentials).length > 0) { + const configIO = new ConfigIO(); + const target = context.awsTargets[0]; + const existingState = await configIO.readDeployedState().catch(() => ({ targets: {} } as DeployedState)); + const targetState = existingState.targets?.[target!.name] ?? { resources: {} }; + targetState.resources ??= {}; + targetState.resources.credentials = deployedCredentials; + if (identityResult.kmsKeyArn) targetState.resources.identityKmsKeyArn = identityResult.kmsKeyArn; + await configIO.writeDeployedState({ + ...existingState, + targets: { ...existingState.targets, [target!.name]: targetState }, + }); + } + // Clear runtime credentials setRuntimeCredentials(null); + // Re-synth now that credentials are in deployed state + updateStep(STEP_SYNTH, { status: 'running' }); + logger.startStep('Synthesize CloudFormation'); + try { + const synthResult = await synthesizeCdk(context.cdkProject, { + ioHost: switchableIoHost.ioHost, + previousWrapper: wrapperRef.current, + }); + wrapperRef.current = synthResult.toolkitWrapper; + setCdkToolkitWrapper(synthResult.toolkitWrapper); + setStackNames(synthResult.stackNames); + logger.endStep('success'); + updateStep(STEP_SYNTH, { status: 'success' }); + } catch (err) { + const errorMsg = formatError(err); + logger.endStep('error', errorMsg); + updateStep(STEP_SYNTH, { status: 'error', error: logger.getFailureMessage('Synthesize CloudFormation') }); + setPhase('error'); + isRunningRef.current = false; + return; + } + // Check if bootstrap is needed const bootstrapCheck = await checkBootstrapNeeded(context.awsTargets); if (bootstrapCheck.needsBootstrap && bootstrapCheck.target) { setBootstrapContext({ - toolkitWrapper: wrapperRef.current!, + toolkitWrapper: wrapperRef.current, target: bootstrapCheck.target, }); setPhase('bootstrap-confirm'); From 8c44fb1b9e5c7576ea2f6ed18e926fc779bec33e Mon Sep 17 00:00:00 2001 From: Aidan Daly <99039782+aidandaly24@users.noreply.github.com> Date: Thu, 26 Feb 2026 10:53:47 -0500 Subject: [PATCH 19/28] feat: set gateway env vars in agentcore dev for local testing (#428) Read deployed-state.json for gateway URLs and mcp.json for auth types, then set AGENTCORE_GATEWAY_{NAME}_URL and AGENTCORE_GATEWAY_{NAME}_AUTH_TYPE env vars when running agentcore dev locally. - New gateway-env.ts helper iterates all deployment targets - Integrated in both CLI dev command and TUI dev hook - .env.local values take precedence over gateway env vars - Graceful fallback when no deployed state exists - Fixed parseGatewayOutputs to parse Id, Arn, and Url outputs separately - Added gatewayUrl field to deployed-state schema (optional, backward compat) --- .../cloudformation/__tests__/outputs.test.ts | 25 +++++++++++----- src/cli/cloudformation/outputs.ts | 22 +++++++++----- src/cli/commands/dev/command.tsx | 6 +++- src/cli/operations/dev/gateway-env.ts | 30 +++++++++++++++++++ src/cli/tui/hooks/useDevServer.ts | 6 +++- src/schema/schemas/deployed-state.ts | 1 + 6 files changed, 72 insertions(+), 18 deletions(-) create mode 100644 src/cli/operations/dev/gateway-env.ts diff --git a/src/cli/cloudformation/__tests__/outputs.test.ts b/src/cli/cloudformation/__tests__/outputs.test.ts index 6d1091f3..39745b4a 100644 --- a/src/cli/cloudformation/__tests__/outputs.test.ts +++ b/src/cli/cloudformation/__tests__/outputs.test.ts @@ -112,9 +112,13 @@ describe('buildDeployedState', () => { }); describe('parseGatewayOutputs', () => { - it('extracts gateway URL from outputs matching pattern', () => { + it('extracts gateway outputs matching pattern', () => { const outputs = { + GatewayMyGatewayIdOutput3E11FAB4: 'gw-123', + GatewayMyGatewayArnOutput3E11FAB4: 'arn:aws:bedrock:us-east-1:123:gateway/gw-123', GatewayMyGatewayUrlOutput3E11FAB4: 'https://api.gateway.url', + GatewayAnotherGatewayIdOutputABC123: 'gw-456', + GatewayAnotherGatewayArnOutputABC123: 'arn:aws:bedrock:us-east-1:123:gateway/gw-456', GatewayAnotherGatewayUrlOutputABC123: 'https://another.gateway.url', UnrelatedOutput: 'some-value', }; @@ -128,12 +132,14 @@ describe('parseGatewayOutputs', () => { expect(result).toEqual({ 'my-gateway': { - gatewayId: 'my-gateway', - gatewayArn: 'https://api.gateway.url', + gatewayId: 'gw-123', + gatewayArn: 'arn:aws:bedrock:us-east-1:123:gateway/gw-123', + gatewayUrl: 'https://api.gateway.url', }, 'another-gateway': { - gatewayId: 'another-gateway', - gatewayArn: 'https://another.gateway.url', + gatewayId: 'gw-456', + gatewayArn: 'arn:aws:bedrock:us-east-1:123:gateway/gw-456', + gatewayUrl: 'https://another.gateway.url', }, }); }); @@ -155,8 +161,11 @@ describe('parseGatewayOutputs', () => { it('maps multiple gateways correctly', () => { const outputs = { + GatewayFirstGatewayArnOutput123: 'arn:first', GatewayFirstGatewayUrlOutput123: 'https://first.url', + GatewaySecondGatewayArnOutput456: 'arn:second', GatewaySecondGatewayUrlOutput456: 'https://second.url', + GatewayThirdGatewayArnOutput789: 'arn:third', GatewayThirdGatewayUrlOutput789: 'https://third.url', }; @@ -169,8 +178,8 @@ describe('parseGatewayOutputs', () => { const result = parseGatewayOutputs(outputs, gatewaySpecs); expect(Object.keys(result)).toHaveLength(3); - expect(result['first-gateway']?.gatewayArn).toBe('https://first.url'); - expect(result['second-gateway']?.gatewayArn).toBe('https://second.url'); - expect(result['third-gateway']?.gatewayArn).toBe('https://third.url'); + expect(result['first-gateway']?.gatewayUrl).toBe('https://first.url'); + expect(result['second-gateway']?.gatewayUrl).toBe('https://second.url'); + expect(result['third-gateway']?.gatewayUrl).toBe('https://third.url'); }); }); diff --git a/src/cli/cloudformation/outputs.ts b/src/cli/cloudformation/outputs.ts index 9cf5ae77..8f4ba433 100644 --- a/src/cli/cloudformation/outputs.ts +++ b/src/cli/cloudformation/outputs.ts @@ -38,8 +38,8 @@ export async function getStackOutputs(region: string, stackName: string): Promis export function parseGatewayOutputs( outputs: StackOutputs, gatewaySpecs: Record -): Record { - const gateways: Record = {}; +): Record { + const gateways: Record = {}; // Map PascalCase gateway names to original names for lookup const gatewayNames = Object.keys(gatewaySpecs); @@ -53,15 +53,21 @@ export function parseGatewayOutputs( if (!match) continue; const logicalGateway = match[1]; - if (!logicalGateway) continue; + const outputType = match[2]; + if (!logicalGateway || !outputType) continue; // Look up original gateway name from PascalCase version const gatewayName = gatewayIdMap.get(logicalGateway) ?? logicalGateway; - gateways[gatewayName] = { - gatewayId: gatewayName, - gatewayArn: value, - }; + gateways[gatewayName] ??= { gatewayId: gatewayName, gatewayArn: '' }; + + if (outputType === 'Id') { + gateways[gatewayName].gatewayId = value; + } else if (outputType === 'Arn') { + gateways[gatewayName].gatewayArn = value; + } else if (outputType === 'Url') { + gateways[gatewayName].gatewayUrl = value; + } } return gateways; @@ -173,7 +179,7 @@ export function buildDeployedState( targetName: string, stackName: string, agents: Record, - gateways: Record, + gateways: Record, existingState?: DeployedState, identityKmsKeyArn?: string, credentials?: Record diff --git a/src/cli/commands/dev/command.tsx b/src/cli/commands/dev/command.tsx index 427c04a6..374f446e 100644 --- a/src/cli/commands/dev/command.tsx +++ b/src/cli/commands/dev/command.tsx @@ -11,6 +11,7 @@ import { invokeAgentStreaming, loadProjectConfig, } from '../../operations/dev'; +import { getGatewayEnvVars } from '../../operations/dev/gateway-env.js'; import { FatalError } from '../../tui/components'; import { LayoutProvider } from '../../tui/context'; import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; @@ -123,6 +124,9 @@ export const registerDev = (program: Command) => { const agentName = opts.agent ?? project.agents[0]?.name; const configRoot = findConfigRoot(workingDir); const envVars = configRoot ? await readEnvFile(configRoot) : {}; + const gatewayEnvVars = await getGatewayEnvVars(); + // Gateway env vars go first, .env.local overrides take precedence + const mergedEnvVars = { ...gatewayEnvVars, ...envVars }; const config = getDevConfig(workingDir, project, configRoot ?? undefined, agentName); if (!config) { @@ -164,7 +168,7 @@ export const registerDev = (program: Command) => { }, }; - const server = createDevServer(config, { port: actualPort, envVars, callbacks: devCallbacks }); + const server = createDevServer(config, { port: actualPort, envVars: mergedEnvVars, callbacks: devCallbacks }); await server.start(); // Handle Ctrl+C — use server.kill() for proper container cleanup diff --git a/src/cli/operations/dev/gateway-env.ts b/src/cli/operations/dev/gateway-env.ts new file mode 100644 index 00000000..78d43bcf --- /dev/null +++ b/src/cli/operations/dev/gateway-env.ts @@ -0,0 +1,30 @@ +import { ConfigIO } from '../../../lib/index.js'; + +export async function getGatewayEnvVars(): Promise> { + const configIO = new ConfigIO(); + const envVars: Record = {}; + + try { + const deployedState = await configIO.readDeployedState(); + const mcpSpec = configIO.configExists('mcp') ? await configIO.readMcpSpec() : undefined; + + // Iterate all targets (not just 'default') + for (const target of Object.values(deployedState?.targets ?? {})) { + const gateways = target?.resources?.mcp?.gateways ?? {}; + + for (const [name, gateway] of Object.entries(gateways)) { + if (!gateway.gatewayUrl) continue; + const sanitized = name.toUpperCase().replace(/-/g, '_'); + envVars[`AGENTCORE_GATEWAY_${sanitized}_URL`] = gateway.gatewayUrl; + + const gatewaySpec = mcpSpec?.agentCoreGateways?.find(g => g.name === name); + const authType = gatewaySpec?.authorizerType ?? 'NONE'; + envVars[`AGENTCORE_GATEWAY_${sanitized}_AUTH_TYPE`] = authType; + } + } + } catch { + // No deployed state or mcp.json — skip gateway env vars + } + + return envVars; +} diff --git a/src/cli/tui/hooks/useDevServer.ts b/src/cli/tui/hooks/useDevServer.ts index 61230c84..268aaad1 100644 --- a/src/cli/tui/hooks/useDevServer.ts +++ b/src/cli/tui/hooks/useDevServer.ts @@ -14,6 +14,7 @@ import { loadProjectConfig, waitForPort, } from '../../operations/dev'; +import { getGatewayEnvVars } from '../../operations/dev/gateway-env.js'; import { useEffect, useMemo, useRef, useState } from 'react'; type ServerStatus = 'starting' | 'running' | 'error' | 'stopped'; @@ -78,7 +79,10 @@ export function useDevServer(options: { workingDir: string; port: number; agentN // Load env vars from agentcore/.env if (root) { const vars = await readEnvFile(root); - setEnvVars(vars); + const gatewayEnvVars = await getGatewayEnvVars(); + // Gateway env vars go first, .env.local overrides take precedence + const mergedEnvVars = { ...gatewayEnvVars, ...vars }; + setEnvVars(mergedEnvVars); } setConfigLoaded(true); diff --git a/src/schema/schemas/deployed-state.ts b/src/schema/schemas/deployed-state.ts index be6efacd..b82b40a7 100644 --- a/src/schema/schemas/deployed-state.ts +++ b/src/schema/schemas/deployed-state.ts @@ -24,6 +24,7 @@ export type AgentCoreDeployedState = z.infer; From 5b3cd235e3c86501f0d508900ac41ff6d6155a90 Mon Sep 17 00:00:00 2001 From: Aidan Daly <99039782+aidandaly24@users.noreply.github.com> Date: Thu, 26 Feb 2026 10:58:49 -0500 Subject: [PATCH 20/28] fix: prettier formatting for upstream files (#461) --- src/assets/cdk/bin/cdk.ts | 8 ++++++-- src/cli/commands/deploy/actions.ts | 4 ++-- src/cli/tui/hooks/useCdkPreflight.ts | 7 +++++-- src/cli/tui/screens/mcp/AddGatewayFlow.tsx | 8 +------- src/cli/tui/screens/mcp/AddGatewayScreen.tsx | 7 +------ 5 files changed, 15 insertions(+), 19 deletions(-) diff --git a/src/assets/cdk/bin/cdk.ts b/src/assets/cdk/bin/cdk.ts index ef16d28d..9b23d57d 100644 --- a/src/assets/cdk/bin/cdk.ts +++ b/src/assets/cdk/bin/cdk.ts @@ -55,9 +55,13 @@ async function main() { const stackName = toStackName(spec.name, target.name); // Extract credentials from deployed state for this target - const targetState = (deployedState as Record)?.targets as Record> | undefined; + const targetState = (deployedState as Record)?.targets as + | Record> + | undefined; const targetResources = targetState?.[target.name]?.resources as Record | undefined; - const credentials = targetResources?.credentials as Record | undefined; + const credentials = targetResources?.credentials as + | Record + | undefined; new AgentCoreStack(app, stackName, { spec, diff --git a/src/cli/commands/deploy/actions.ts b/src/cli/commands/deploy/actions.ts index 70cd4bcc..2b4dbd94 100644 --- a/src/cli/commands/deploy/actions.ts +++ b/src/cli/commands/deploy/actions.ts @@ -1,4 +1,5 @@ import { ConfigIO, SecureCredentials } from '../../../lib'; +import type { DeployedState } from '../../../schema'; import { validateAwsCredentials } from '../../aws/account'; import { createSwitchableIoHost } from '../../cdk/toolkit-lib'; import { buildDeployedState, getStackOutputs, parseAgentOutputs, parseGatewayOutputs } from '../../cloudformation'; @@ -20,7 +21,6 @@ import { } from '../../operations/deploy'; import { formatTargetStatus, getGatewayTargetStatuses } from '../../operations/deploy/gateway-status'; import type { DeployResult } from './types'; -import type { DeployedState } from '../../../schema'; export interface ValidatedDeployOptions { target: string; @@ -190,7 +190,7 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise 0) { - const existingPreSynthState = await configIO.readDeployedState().catch(() => ({targets: {}} as DeployedState)); + const existingPreSynthState = await configIO.readDeployedState().catch(() => ({ targets: {} }) as DeployedState); const targetState = existingPreSynthState.targets?.[target.name] ?? { resources: {} }; targetState.resources ??= {}; targetState.resources.credentials = deployedCredentials; diff --git a/src/cli/tui/hooks/useCdkPreflight.ts b/src/cli/tui/hooks/useCdkPreflight.ts index 9b95c742..c669adaa 100644 --- a/src/cli/tui/hooks/useCdkPreflight.ts +++ b/src/cli/tui/hooks/useCdkPreflight.ts @@ -364,7 +364,10 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { // Check if API key providers need setup before CDK synth (CDK needs credential ARNs) // Skip this check if skipIdentityCheck is true (e.g., plan command only synthesizes) - const needsCredentialSetup = !skipIdentityCheck && (hasIdentityApiProviders(preflightContext.projectSpec) || hasIdentityOAuthProviders(preflightContext.projectSpec)); + const needsCredentialSetup = + !skipIdentityCheck && + (hasIdentityApiProviders(preflightContext.projectSpec) || + hasIdentityOAuthProviders(preflightContext.projectSpec)); if (needsCredentialSetup) { // Get all credentials for the prompt (not just missing ones) const allCredentials = getAllCredentials(preflightContext.projectSpec); @@ -641,7 +644,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { if (Object.keys(deployedCredentials).length > 0) { const configIO = new ConfigIO(); const target = context.awsTargets[0]; - const existingState = await configIO.readDeployedState().catch(() => ({ targets: {} } as DeployedState)); + const existingState = await configIO.readDeployedState().catch(() => ({ targets: {} }) as DeployedState); const targetState = existingState.targets?.[target!.name] ?? { resources: {} }; targetState.resources ??= {}; targetState.resources.credentials = deployedCredentials; diff --git a/src/cli/tui/screens/mcp/AddGatewayFlow.tsx b/src/cli/tui/screens/mcp/AddGatewayFlow.tsx index c863c421..0f99cc83 100644 --- a/src/cli/tui/screens/mcp/AddGatewayFlow.tsx +++ b/src/cli/tui/screens/mcp/AddGatewayFlow.tsx @@ -21,13 +21,7 @@ interface AddGatewayFlowProps { onDeploy?: () => void; } -export function AddGatewayFlow({ - isInteractive = true, - onExit, - onBack, - onDev, - onDeploy, -}: AddGatewayFlowProps) { +export function AddGatewayFlow({ isInteractive = true, onExit, onBack, onDev, onDeploy }: AddGatewayFlowProps) { const { createGateway, reset: resetCreate } = useCreateGateway(); const { gateways: existingGateways, refresh: refreshGateways } = useExistingGateways(); const { targets: unassignedTargets } = useUnassignedTargets(); diff --git a/src/cli/tui/screens/mcp/AddGatewayScreen.tsx b/src/cli/tui/screens/mcp/AddGatewayScreen.tsx index 4301657c..13269eef 100644 --- a/src/cli/tui/screens/mcp/AddGatewayScreen.tsx +++ b/src/cli/tui/screens/mcp/AddGatewayScreen.tsx @@ -26,12 +26,7 @@ interface AddGatewayScreenProps { unassignedTargets: string[]; } -export function AddGatewayScreen({ - onComplete, - onExit, - existingGateways, - unassignedTargets, -}: AddGatewayScreenProps) { +export function AddGatewayScreen({ onComplete, onExit, existingGateways, unassignedTargets }: AddGatewayScreenProps) { const wizard = useAddGatewayWizard(unassignedTargets.length); // JWT config sub-step tracking (0 = discoveryUrl, 1 = audience, 2 = clients) From a42f677eedf1dbe70ec38e2d0f37373851e00203 Mon Sep 17 00:00:00 2001 From: Aidan Daly <99039782+aidandaly24@users.noreply.github.com> Date: Thu, 26 Feb 2026 12:56:51 -0500 Subject: [PATCH 21/28] refactor: remove dead bind flow and rename MCP Tool to Gateway Target (#459) * refactor: remove mode selection and bind flow from gateway target wizard * fix: rename 'MCP Tool' to 'Gateway Target' in UI labels, CLI output, and comments * fix: update CDK asset snapshot for cdk/bin/cdk.ts * fix: prettier formatting for AddGatewayTargetFlow.tsx --- .../assets.snapshot.test.ts.snap | 8 +- .../add/__tests__/add-gateway-target.test.ts | 2 +- src/cli/commands/add/actions.ts | 2 +- src/cli/commands/add/command.tsx | 2 +- src/cli/commands/add/types.ts | 2 +- src/cli/commands/add/validate.ts | 2 +- .../__tests__/remove-gateway-target.test.ts | 2 +- src/cli/commands/remove/actions.ts | 4 +- src/cli/commands/remove/command.tsx | 2 +- src/cli/operations/mcp/create-mcp.ts | 8 +- .../remove/remove-gateway-target.ts | 8 +- src/cli/templates/GatewayTargetRenderer.ts | 6 +- src/cli/tui/hooks/useCreateMcp.ts | 2 +- src/cli/tui/screens/add/AddFlow.tsx | 2 +- src/cli/tui/screens/add/AddScreen.tsx | 4 +- .../screens/add/__tests__/AddScreen.test.tsx | 2 +- .../tui/screens/mcp/AddGatewayTargetFlow.tsx | 213 +----------------- .../screens/mcp/AddGatewayTargetScreen.tsx | 2 +- src/cli/tui/screens/mcp/types.ts | 4 +- src/cli/tui/screens/remove/RemoveFlow.tsx | 10 +- .../remove/RemoveGatewayTargetScreen.tsx | 4 +- src/cli/tui/screens/remove/RemoveScreen.tsx | 4 +- .../remove/__tests__/RemoveScreen.test.tsx | 2 +- src/schema/schemas/mcp-defs.ts | 2 +- 24 files changed, 55 insertions(+), 244 deletions(-) diff --git a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap index c2d3adbe..02841a5b 100644 --- a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap +++ b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap @@ -100,9 +100,13 @@ async function main() { const stackName = toStackName(spec.name, target.name); // Extract credentials from deployed state for this target - const targetState = (deployedState as Record)?.targets as Record> | undefined; + const targetState = (deployedState as Record)?.targets as + | Record> + | undefined; const targetResources = targetState?.[target.name]?.resources as Record | undefined; - const credentials = targetResources?.credentials as Record | undefined; + const credentials = targetResources?.credentials as + | Record + | undefined; new AgentCoreStack(app, stackName, { spec, diff --git a/src/cli/commands/add/__tests__/add-gateway-target.test.ts b/src/cli/commands/add/__tests__/add-gateway-target.test.ts index 4b0ee8ca..bbec3694 100644 --- a/src/cli/commands/add/__tests__/add-gateway-target.test.ts +++ b/src/cli/commands/add/__tests__/add-gateway-target.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -// MCP Tool feature is disabled (coming soon) - skip all tests +// Gateway Target feature is disabled (coming soon) - skip all tests describe.skip('add gateway-target command', () => { let testDir: string; let projectDir: string; diff --git a/src/cli/commands/add/actions.ts b/src/cli/commands/add/actions.ts index 0e2543f9..675c52e8 100644 --- a/src/cli/commands/add/actions.ts +++ b/src/cli/commands/add/actions.ts @@ -283,7 +283,7 @@ export async function handleAddGateway(options: ValidatedAddGatewayOptions): Pro } } -// MCP Tool handler +// Gateway Target handler export function buildGatewayTargetConfig(options: ValidatedAddGatewayTargetOptions): AddGatewayTargetConfig { const sourcePath = `${APP_DIR}/${MCP_APP_SUBDIR}/${options.name}`; diff --git a/src/cli/commands/add/command.tsx b/src/cli/commands/add/command.tsx index 2db2b40a..ebc0ff44 100644 --- a/src/cli/commands/add/command.tsx +++ b/src/cli/commands/add/command.tsx @@ -131,7 +131,7 @@ async function handleAddGatewayTargetCLI(options: AddGatewayTargetOptions): Prom if (options.json) { console.log(JSON.stringify(result)); } else if (result.success) { - console.log(`Added MCP tool '${result.toolName}'`); + console.log(`Added gateway target '${result.toolName}'`); if (result.sourcePath) { console.log(`Tool code: ${result.sourcePath}`); } diff --git a/src/cli/commands/add/types.ts b/src/cli/commands/add/types.ts index f8847a7f..c83db76d 100644 --- a/src/cli/commands/add/types.ts +++ b/src/cli/commands/add/types.ts @@ -41,7 +41,7 @@ export interface AddGatewayResult { error?: string; } -// MCP Tool types +// Gateway Target types export interface AddGatewayTargetOptions { name?: string; description?: string; diff --git a/src/cli/commands/add/validate.ts b/src/cli/commands/add/validate.ts index 2731ed83..b11e2aa7 100644 --- a/src/cli/commands/add/validate.ts +++ b/src/cli/commands/add/validate.ts @@ -183,7 +183,7 @@ export function validateAddGatewayOptions(options: AddGatewayOptions): Validatio return { valid: true }; } -// MCP Tool validation +// Gateway Target validation export async function validateAddGatewayTargetOptions(options: AddGatewayTargetOptions): Promise { if (!options.name) { return { valid: false, error: '--name is required' }; diff --git a/src/cli/commands/remove/__tests__/remove-gateway-target.test.ts b/src/cli/commands/remove/__tests__/remove-gateway-target.test.ts index 33c272fa..20b65e1e 100644 --- a/src/cli/commands/remove/__tests__/remove-gateway-target.test.ts +++ b/src/cli/commands/remove/__tests__/remove-gateway-target.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -// MCP Tool feature is disabled (coming soon) - skip all tests +// Gateway Target feature is disabled (coming soon) - skip all tests describe.skip('remove gateway-target command', () => { let testDir: string; let projectDir: string; diff --git a/src/cli/commands/remove/actions.ts b/src/cli/commands/remove/actions.ts index 3a9ad9e8..35681c69 100644 --- a/src/cli/commands/remove/actions.ts +++ b/src/cli/commands/remove/actions.ts @@ -49,14 +49,14 @@ export async function handleRemove(options: ValidatedRemoveOptions): Promise t.name === name); - if (!tool) return { success: false, error: `MCP tool '${name}' not found` }; + if (!tool) return { success: false, error: `Gateway target '${name}' not found` }; const result = await removeGatewayTarget(tool); if (!result.ok) return { success: false, error: result.error }; return { success: true, resourceType, resourceName: name, - message: `Removed MCP tool '${name}'`, + message: `Removed gateway target '${name}'`, note: SOURCE_CODE_NOTE, }; } diff --git a/src/cli/commands/remove/command.tsx b/src/cli/commands/remove/command.tsx index 97da68f6..6b14ba3f 100644 --- a/src/cli/commands/remove/command.tsx +++ b/src/cli/commands/remove/command.tsx @@ -142,7 +142,7 @@ export const registerRemove = (program: Command) => { registerResourceRemove(removeCommand, 'memory', 'memory', 'Remove a memory provider from the project'); registerResourceRemove(removeCommand, 'identity', 'identity', 'Remove an identity provider from the project'); - registerResourceRemove(removeCommand, 'gateway-target', 'gateway-target', 'Remove an MCP tool from the project'); + registerResourceRemove(removeCommand, 'gateway-target', 'gateway-target', 'Remove a gateway target from the project'); registerResourceRemove(removeCommand, 'gateway', 'gateway', 'Remove a gateway from the project'); diff --git a/src/cli/operations/mcp/create-mcp.ts b/src/cli/operations/mcp/create-mcp.ts index d6e36a1f..aefd731c 100644 --- a/src/cli/operations/mcp/create-mcp.ts +++ b/src/cli/operations/mcp/create-mcp.ts @@ -45,7 +45,7 @@ async function readMcpDefs(filePath: string): Promise { const parsed = JSON.parse(raw) as unknown; const result = AgentCoreCliMcpDefsSchema.safeParse(parsed); if (!result.success) { - throw new Error('Invalid mcp-defs.json. Fix it before adding a new MCP tool.'); + throw new Error('Invalid mcp-defs.json. Fix it before adding a new gateway target.'); } return result.data; } @@ -211,7 +211,7 @@ export async function createGatewayFromWizard(config: AddGatewayConfig): Promise function validateGatewayTargetLanguage(language: string): asserts language is 'Python' | 'TypeScript' | 'Other' { if (language !== 'Python' && language !== 'TypeScript' && language !== 'Other') { - throw new Error(`MCP tools for language "${language}" are not yet supported.`); + throw new Error(`Gateway targets for language "${language}" are not yet supported.`); } } @@ -288,7 +288,7 @@ export async function createExternalGatewayTarget(config: AddGatewayTargetConfig } /** - * Create an MCP tool (behind gateway only). + * Create a gateway target (behind gateway only). */ export async function createToolFromWizard(config: AddGatewayTargetConfig): Promise { validateGatewayTargetLanguage(config.language); @@ -400,7 +400,7 @@ export async function createToolFromWizard(config: AddGatewayTargetConfig): Prom throw new Error(`MCP saved, but failed to update mcp-defs.json: ${message}`); } - // Render MCP tool project template + // Render gateway target project template // Resolve absolute path from project root const configRoot = requireConfigRoot(); const projectRoot = dirname(configRoot); diff --git a/src/cli/operations/remove/remove-gateway-target.ts b/src/cli/operations/remove/remove-gateway-target.ts index f3aacb5d..88fdc004 100644 --- a/src/cli/operations/remove/remove-gateway-target.ts +++ b/src/cli/operations/remove/remove-gateway-target.ts @@ -6,7 +6,7 @@ import { rm } from 'fs/promises'; import { join } from 'path'; /** - * Represents an MCP tool that can be removed. + * Represents a gateway target that can be removed. */ export interface RemovableGatewayTarget { name: string; @@ -15,7 +15,7 @@ export interface RemovableGatewayTarget { } /** - * Get list of MCP tools available for removal. + * Get list of gateway targets available for removal. */ export async function getRemovableGatewayTargets(): Promise { try { @@ -44,7 +44,7 @@ export async function getRemovableGatewayTargets(): Promise { const configIO = new ConfigIO(); @@ -150,7 +150,7 @@ function computeRemovedToolMcpDefs( } /** - * Remove an MCP tool from the project. + * Remove a gateway target from the project. */ export async function removeGatewayTarget(tool: RemovableGatewayTarget): Promise { try { diff --git a/src/cli/templates/GatewayTargetRenderer.ts b/src/cli/templates/GatewayTargetRenderer.ts index 12e25f4d..2680e73c 100644 --- a/src/cli/templates/GatewayTargetRenderer.ts +++ b/src/cli/templates/GatewayTargetRenderer.ts @@ -44,7 +44,7 @@ export const LAMBDA_TEMPLATE_TOOLS: ToolDefinition[] = [ */ export function getTemplateToolDefinitions(toolName: string, host: ComputeHost): ToolDefinition[] { if (host === 'Lambda') { - // Prefix template tool names with the MCP tool name to avoid conflicts + // Prefix template tool names with the gateway target name to avoid conflicts // when adding multiple Lambda tools to the same project return LAMBDA_TEMPLATE_TOOLS.map(tool => ({ ...tool, @@ -62,7 +62,7 @@ export function getTemplateToolDefinitions(toolName: string, host: ComputeHost): } /** - * Renders an MCP tool project template to the specified output directory. + * Renders a gateway target project template to the specified output directory. * @param toolName - Name of the tool (used for {{ Name }} substitution) * @param outputDir - Target directory for the project * @param language - Target language ('Python' or 'TypeScript') @@ -75,7 +75,7 @@ export async function renderGatewayTargetTemplate( host: ComputeHost = 'AgentCoreRuntime' ): Promise { if (language !== 'Python') { - throw new Error(`MCP tool templates for ${language} are not yet supported.`); + throw new Error(`Gateway target templates for ${language} are not yet supported.`); } // Select template based on compute host diff --git a/src/cli/tui/hooks/useCreateMcp.ts b/src/cli/tui/hooks/useCreateMcp.ts index 2b2b4857..9bef0c75 100644 --- a/src/cli/tui/hooks/useCreateMcp.ts +++ b/src/cli/tui/hooks/useCreateMcp.ts @@ -49,7 +49,7 @@ export function useCreateGatewayTarget() { setStatus({ state: 'success', result }); return { ok: true as const, result }; } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to create MCP tool.'; + const message = err instanceof Error ? err.message : 'Failed to create gateway target.'; setStatus({ state: 'error', error: message }); return { ok: false as const, error: message }; } diff --git a/src/cli/tui/screens/add/AddFlow.tsx b/src/cli/tui/screens/add/AddFlow.tsx index dab5eb13..fbead7de 100644 --- a/src/cli/tui/screens/add/AddFlow.tsx +++ b/src/cli/tui/screens/add/AddFlow.tsx @@ -333,7 +333,7 @@ export function AddFlow(props: AddFlowProps) { ); } - // MCP Tool wizard - now uses AddGatewayTargetFlow with mode selection + // Gateway Target wizard - uses AddGatewayTargetFlow if (flow.name === 'tool-wizard') { return ( { const { lastFrame } = render(); expect(lastFrame()).toContain('Gateway'); - expect(lastFrame()).toContain('MCP Tool'); + expect(lastFrame()).toContain('Gateway Target'); expect(lastFrame()).not.toContain('Add an agent first'); }); }); diff --git a/src/cli/tui/screens/mcp/AddGatewayTargetFlow.tsx b/src/cli/tui/screens/mcp/AddGatewayTargetFlow.tsx index aa6a5148..c6cce11d 100644 --- a/src/cli/tui/screens/mcp/AddGatewayTargetFlow.tsx +++ b/src/cli/tui/screens/mcp/AddGatewayTargetFlow.tsx @@ -1,23 +1,14 @@ import { createExternalGatewayTarget } from '../../../operations/mcp/create-mcp'; -import { ErrorPrompt, Panel, Screen, TextInput, WizardSelect } from '../../components'; -import type { SelectableItem } from '../../components'; -import { HELP_TEXT } from '../../constants'; -import { useListNavigation } from '../../hooks'; -import { useAgents, useBindMcpRuntime, useMcpRuntimeTools } from '../../hooks/useAttach'; +import { ErrorPrompt } from '../../components'; import { useCreateGatewayTarget, useExistingGateways, useExistingToolNames } from '../../hooks/useCreateMcp'; import { AddSuccessScreen } from '../add/AddSuccessScreen'; import { AddGatewayTargetScreen } from './AddGatewayTargetScreen'; import type { AddGatewayTargetConfig } from './types'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; type FlowState = - | { name: 'mode-select' } | { name: 'create-wizard' } - | { name: 'bind-select-runtime' } - | { name: 'bind-select-agent'; mcpRuntimeName: string } - | { name: 'bind-enter-envvar'; mcpRuntimeName: string; targetAgent: string } | { name: 'create-success'; toolName: string; projectPath: string; loading?: boolean; loadingMessage?: string } - | { name: 'bind-success'; mcpRuntimeName: string; targetAgent: string } | { name: 'error'; message: string }; interface AddGatewayTargetFlowProps { @@ -31,11 +22,6 @@ interface AddGatewayTargetFlowProps { onDeploy?: () => void; } -const MODE_OPTIONS: SelectableItem[] = [ - { id: 'create', title: 'Create new MCP tool', description: 'Define a new MCP tool project' }, - { id: 'bind', title: 'Bind existing MCP runtime', description: 'Add an agent to an existing MCP runtime' }, -]; - export function AddGatewayTargetFlow({ isInteractive = true, onExit, @@ -46,63 +32,15 @@ export function AddGatewayTargetFlow({ const { createTool, reset: resetCreate } = useCreateGatewayTarget(); const { gateways: existingGateways } = useExistingGateways(); const { toolNames: existingToolNames } = useExistingToolNames(); - const [flow, setFlow] = useState({ name: 'mode-select' }); - - // Bind flow hooks - const { agents: allAgents, isLoading: isLoadingAgents } = useAgents(); - const { tools: mcpRuntimeTools } = useMcpRuntimeTools(); - const { bind: bindMcpRuntime } = useBindMcpRuntime(); + const [flow, setFlow] = useState({ name: 'create-wizard' }); // In non-interactive mode, exit after success (but not while loading) useEffect(() => { - if (!isInteractive) { - if ((flow.name === 'create-success' && !flow.loading) || flow.name === 'bind-success') { - onExit(); - } + if (!isInteractive && flow.name === 'create-success' && !flow.loading) { + onExit(); } }, [isInteractive, flow, onExit]); - // Mode selection navigation - const modeNav = useListNavigation({ - items: MODE_OPTIONS, - onSelect: item => { - if (item.id === 'create') { - setFlow({ name: 'create-wizard' }); - } else { - setFlow({ name: 'bind-select-runtime' }); - } - }, - onExit: onBack, - isActive: flow.name === 'mode-select', - }); - - // MCP Runtime selection for bind flow - const runtimeItems: SelectableItem[] = useMemo( - () => mcpRuntimeTools.map(name => ({ id: name, title: name })), - [mcpRuntimeTools] - ); - - const runtimeNav = useListNavigation({ - items: runtimeItems, - onSelect: item => setFlow({ name: 'bind-select-agent', mcpRuntimeName: item.id }), - onExit: () => setFlow({ name: 'mode-select' }), - isActive: flow.name === 'bind-select-runtime', - }); - - // Agent selection for bind flow - const agentItems: SelectableItem[] = useMemo(() => allAgents.map(name => ({ id: name, title: name })), [allAgents]); - - const agentNav = useListNavigation({ - items: agentItems, - onSelect: item => { - if (flow.name === 'bind-select-agent') { - setFlow({ name: 'bind-enter-envvar', mcpRuntimeName: flow.mcpRuntimeName, targetAgent: item.id }); - } - }, - onExit: () => setFlow({ name: 'bind-select-runtime' }), - isActive: flow.name === 'bind-select-agent', - }); - const handleCreateComplete = useCallback( (config: AddGatewayTargetConfig) => { setFlow({ @@ -110,7 +48,7 @@ export function AddGatewayTargetFlow({ toolName: config.name, projectPath: '', loading: true, - loadingMessage: 'Creating MCP tool...', + loadingMessage: 'Creating gateway target...', }); if (config.source === 'existing-endpoint') { @@ -135,55 +73,6 @@ export function AddGatewayTargetFlow({ [createTool] ); - const handleBindComplete = useCallback( - async (envVarName: string) => { - if (flow.name !== 'bind-enter-envvar') return; - - const result = await bindMcpRuntime(flow.mcpRuntimeName, { - agentName: flow.targetAgent, - envVarName, - }); - - if (result.ok) { - setFlow({ name: 'bind-success', mcpRuntimeName: flow.mcpRuntimeName, targetAgent: flow.targetAgent }); - } else { - setFlow({ name: 'error', message: result.error }); - } - }, - [flow, bindMcpRuntime] - ); - - // Mode selection screen - if (flow.name === 'mode-select') { - // Check if there are MCP runtimes to bind - const hasRuntimesToBind = mcpRuntimeTools.length > 0; - - // If no MCP runtimes exist to bind, skip to create - if (!hasRuntimesToBind) { - return ( - - ); - } - - return ( - - - - - - ); - } - // Create wizard if (flow.name === 'create-wizard') { return ( @@ -191,83 +80,17 @@ export function AddGatewayTargetFlow({ existingGateways={existingGateways} existingToolNames={existingToolNames} onComplete={handleCreateComplete} - onExit={() => setFlow({ name: 'mode-select' })} + onExit={onBack} /> ); } - // Bind flow - select MCP runtime - if (flow.name === 'bind-select-runtime') { - return ( - setFlow({ name: 'mode-select' })} - helpText={HELP_TEXT.NAVIGATE_SELECT} - > - - - - - ); - } - - // Bind flow - select agent - if (flow.name === 'bind-select-agent') { - if (isLoadingAgents) { - return null; - } - return ( - setFlow({ name: 'bind-select-runtime' })} - helpText={HELP_TEXT.NAVIGATE_SELECT} - > - - - - - ); - } - - // Bind flow - enter env var name - if (flow.name === 'bind-enter-envvar') { - const defaultEnvVar = `${flow.mcpRuntimeName.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_MCP_RUNTIME_URL`; - return ( - setFlow({ name: 'bind-select-agent', mcpRuntimeName: flow.mcpRuntimeName })} - helpText={HELP_TEXT.TEXT_INPUT} - > - - void handleBindComplete(value)} - onCancel={() => setFlow({ name: 'bind-select-agent', mcpRuntimeName: flow.mcpRuntimeName })} - /> - - - ); - } - // Create success if (flow.name === 'create-success') { return ( - ); - } - // Error return ( { resetCreate(); - setFlow({ name: 'mode-select' }); + setFlow({ name: 'create-wizard' }); }} onExit={onExit} /> diff --git a/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx b/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx index 0c811d43..7364bbe9 100644 --- a/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx +++ b/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx @@ -61,7 +61,7 @@ export function AddGatewayTargetScreen({ const headerContent = ; return ( - + {isGatewayStep && !noGatewaysAvailable && ( = { }; // ───────────────────────────────────────────────────────────────────────────── -// MCP Tool Flow Types +// Gateway Target Flow Types // ───────────────────────────────────────────────────────────────────────────── export type ComputeHost = 'Lambda' | 'AgentCoreRuntime'; /** - * MCP tool wizard steps. + * Gateway target wizard steps. * - name: Tool name input * - language: Target language (Python or TypeScript) * - gateway: Select existing gateway diff --git a/src/cli/tui/screens/remove/RemoveFlow.tsx b/src/cli/tui/screens/remove/RemoveFlow.tsx index 5f299cb5..ae83df72 100644 --- a/src/cli/tui/screens/remove/RemoveFlow.tsx +++ b/src/cli/tui/screens/remove/RemoveFlow.tsx @@ -220,7 +220,7 @@ export function RemoveFlow({ const result = await loadGatewayTargetPreview(tool); if (result.ok) { if (force) { - setFlow({ name: 'loading', message: `Removing MCP tool ${tool.name}...` }); + setFlow({ name: 'loading', message: `Removing gateway target ${tool.name}...` }); const removeResult = await removeGatewayTargetOp(tool, result.preview); if (removeResult.ok) { setFlow({ name: 'tool-success', toolName: tool.name }); @@ -354,7 +354,7 @@ export function RemoveFlow({ async (tool: RemovableGatewayTarget, preview: RemovalPreview) => { pendingResultRef.current = null; setResultReady(false); - setFlow({ name: 'loading', message: `Removing MCP tool ${tool.name}...` }); + setFlow({ name: 'loading', message: `Removing gateway target ${tool.name}...` }); const result = await removeGatewayTargetOp(tool, preview); if (result.ok) { pendingResultRef.current = { name: 'tool-success', toolName: tool.name, logFilePath: result.logFilePath }; @@ -540,7 +540,7 @@ export function RemoveFlow({ if (flow.name === 'confirm-gateway-target') { return ( void handleConfirmGatewayTarget(flow.tool, flow.preview)} onCancel={() => setFlow({ name: 'select-gateway-target' })} @@ -607,8 +607,8 @@ export function RemoveFlow({ return ( { resetAll(); diff --git a/src/cli/tui/screens/remove/RemoveGatewayTargetScreen.tsx b/src/cli/tui/screens/remove/RemoveGatewayTargetScreen.tsx index e61d599c..c7bf5544 100644 --- a/src/cli/tui/screens/remove/RemoveGatewayTargetScreen.tsx +++ b/src/cli/tui/screens/remove/RemoveGatewayTargetScreen.tsx @@ -3,7 +3,7 @@ import { SelectScreen } from '../../components'; import React from 'react'; interface RemoveGatewayTargetScreenProps { - /** List of MCP tools that can be removed */ + /** List of gateway targets that can be removed */ tools: RemovableGatewayTarget[]; /** Called when a tool is selected for removal */ onSelect: (tool: RemovableGatewayTarget) => void; @@ -23,7 +23,7 @@ export function RemoveGatewayTargetScreen({ tools, onSelect, onExit }: RemoveGat return ( { const tool = toolMap.get(item.id); diff --git a/src/cli/tui/screens/remove/RemoveScreen.tsx b/src/cli/tui/screens/remove/RemoveScreen.tsx index b739ba66..f64ddc8b 100644 --- a/src/cli/tui/screens/remove/RemoveScreen.tsx +++ b/src/cli/tui/screens/remove/RemoveScreen.tsx @@ -7,7 +7,7 @@ const REMOVE_RESOURCES = [ { id: 'memory', title: 'Memory', description: 'Remove a memory provider' }, { id: 'identity', title: 'Identity', description: 'Remove an identity provider' }, { id: 'gateway', title: 'Gateway', description: 'Remove an MCP gateway' }, - { id: 'gateway-target', title: 'MCP Tool', description: 'Remove an MCP tool' }, + { id: 'gateway-target', title: 'Gateway Target', description: 'Remove a gateway target' }, { id: 'all', title: 'All', description: 'Reset entire agentcore project' }, ] as const; @@ -20,7 +20,7 @@ interface RemoveScreenProps { agentCount: number; /** Number of gateways available for removal */ gatewayCount: number; - /** Number of MCP tools available for removal */ + /** Number of gateway targets available for removal */ mcpToolCount: number; /** Number of memories available for removal */ memoryCount: number; diff --git a/src/cli/tui/screens/remove/__tests__/RemoveScreen.test.tsx b/src/cli/tui/screens/remove/__tests__/RemoveScreen.test.tsx index 127a86e7..e1e32e05 100644 --- a/src/cli/tui/screens/remove/__tests__/RemoveScreen.test.tsx +++ b/src/cli/tui/screens/remove/__tests__/RemoveScreen.test.tsx @@ -21,7 +21,7 @@ describe('RemoveScreen', () => { ); expect(lastFrame()).toContain('Gateway'); - expect(lastFrame()).toContain('MCP Tool'); + expect(lastFrame()).toContain('Gateway Target'); expect(lastFrame()).not.toContain('No gateways to remove'); expect(lastFrame()).not.toContain('No gateway targets to remove'); }); diff --git a/src/schema/schemas/mcp-defs.ts b/src/schema/schemas/mcp-defs.ts index b9a2b768..ef0b5f48 100644 --- a/src/schema/schemas/mcp-defs.ts +++ b/src/schema/schemas/mcp-defs.ts @@ -51,7 +51,7 @@ export const ToolNameSchema = z ); /** - * MCP Tool Definition schema. + * Gateway Target Definition schema. */ export const ToolDefinitionSchema = z .object({ From f09a152d2535c85cce2da380f3e862f9664ae628 Mon Sep 17 00:00:00 2001 From: Aidan Daly <99039782+aidandaly24@users.noreply.github.com> Date: Thu, 26 Feb 2026 14:04:40 -0500 Subject: [PATCH 22/28] feat: add outbound auth wizard step with inline credential creation (#417) --- .../screens/mcp/AddGatewayTargetScreen.tsx | 303 +++++++++++++++++- src/cli/tui/screens/mcp/types.ts | 16 +- .../screens/mcp/useAddGatewayTargetWizard.ts | 18 +- 3 files changed, 321 insertions(+), 16 deletions(-) diff --git a/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx b/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx index 7364bbe9..30ece187 100644 --- a/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx +++ b/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx @@ -1,14 +1,15 @@ import { ToolNameSchema } from '../../../../schema'; -import { ConfirmReview, Panel, Screen, StepIndicator, TextInput, WizardSelect } from '../../components'; +import { ConfirmReview, Panel, Screen, SecretInput, StepIndicator, TextInput, WizardSelect } from '../../components'; import type { SelectableItem } from '../../components'; import { HELP_TEXT } from '../../constants'; import { useListNavigation } from '../../hooks'; import { generateUniqueName } from '../../utils'; +import { useCreateIdentity, useExistingCredentialNames } from '../identity/useCreateIdentity.js'; import type { AddGatewayTargetConfig } from './types'; -import { MCP_TOOL_STEP_LABELS, SKIP_FOR_NOW } from './types'; +import { MCP_TOOL_STEP_LABELS, OUTBOUND_AUTH_OPTIONS, SKIP_FOR_NOW } from './types'; import { useAddGatewayTargetWizard } from './useAddGatewayTargetWizard'; import { Box, Text } from 'ink'; -import React, { useMemo } from 'react'; +import React, { useMemo, useState } from 'react'; interface AddGatewayTargetScreenProps { existingGateways: string[]; @@ -24,6 +25,17 @@ export function AddGatewayTargetScreen({ onExit, }: AddGatewayTargetScreenProps) { const wizard = useAddGatewayTargetWizard(existingGateways); + const { names: existingCredentialNames } = useExistingCredentialNames(); + const { createIdentity } = useCreateIdentity(); + + // Outbound auth sub-step state + const [outboundAuthType, setOutboundAuthTypeLocal] = useState(null); + const [credentialName, setCredentialNameLocal] = useState(null); + const [isCreatingCredential, setIsCreatingCredential] = useState(false); + const [oauthSubStep, setOauthSubStep] = useState<'name' | 'client-id' | 'client-secret' | 'discovery-url'>('name'); + const [oauthFields, setOauthFields] = useState({ name: '', clientId: '', clientSecret: '', discoveryUrl: '' }); + const [apiKeySubStep, setApiKeySubStep] = useState<'name' | 'api-key'>('name'); + const [apiKeyFields, setApiKeyFields] = useState({ name: '', apiKey: '' }); const gatewayItems: SelectableItem[] = useMemo( () => [ @@ -33,7 +45,23 @@ export function AddGatewayTargetScreen({ [existingGateways] ); + const outboundAuthItems: SelectableItem[] = useMemo( + () => OUTBOUND_AUTH_OPTIONS.map(o => ({ id: o.id, title: o.title, description: o.description })), + [] + ); + + const credentialItems: SelectableItem[] = useMemo(() => { + const items: SelectableItem[] = [ + { id: 'create-new', title: 'Create new credential', description: 'Create a new credential inline' }, + ]; + existingCredentialNames.forEach(name => { + items.push({ id: name, title: name, description: 'Use existing credential' }); + }); + return items; + }, [existingCredentialNames]); + const isGatewayStep = wizard.step === 'gateway'; + const isOutboundAuthStep = wizard.step === 'outbound-auth'; const isTextStep = wizard.step === 'name' || wizard.step === 'endpoint'; const isConfirmStep = wizard.step === 'confirm'; const noGatewaysAvailable = isGatewayStep && existingGateways.length === 0; @@ -45,16 +73,167 @@ export function AddGatewayTargetScreen({ isActive: isGatewayStep && !noGatewaysAvailable, }); + const outboundAuthNav = useListNavigation({ + items: outboundAuthItems, + onSelect: item => { + const authType = item.id as 'OAUTH' | 'API_KEY' | 'NONE'; + setOutboundAuthTypeLocal(authType); + if (authType === 'NONE') { + wizard.setOutboundAuth({ type: 'NONE' }); + } + }, + onExit: () => wizard.goBack(), + isActive: isOutboundAuthStep && !outboundAuthType, + }); + + const credentialNav = useListNavigation({ + items: credentialItems, + onSelect: item => { + if (item.id === 'create-new') { + setIsCreatingCredential(true); + if (outboundAuthType === 'OAUTH') { + setOauthSubStep('name'); + } else { + setApiKeySubStep('name'); + } + } else { + setCredentialNameLocal(item.id); + wizard.setOutboundAuth({ type: outboundAuthType as 'OAUTH' | 'API_KEY', credentialName: item.id }); + } + }, + onExit: () => { + setOutboundAuthTypeLocal(null); + setCredentialNameLocal(null); + setIsCreatingCredential(false); + }, + isActive: + isOutboundAuthStep && + !!outboundAuthType && + outboundAuthType !== 'NONE' && + !credentialName && + !isCreatingCredential, + }); + useListNavigation({ items: [{ id: 'confirm', title: 'Confirm' }], onSelect: () => onComplete(wizard.config), - onExit: () => wizard.goBack(), + onExit: () => { + setOutboundAuthTypeLocal(null); + setCredentialNameLocal(null); + setIsCreatingCredential(false); + setOauthSubStep('name'); + setOauthFields({ name: '', clientId: '', clientSecret: '', discoveryUrl: '' }); + setApiKeySubStep('name'); + setApiKeyFields({ name: '', apiKey: '' }); + wizard.goBack(); + }, isActive: isConfirmStep, }); + // OAuth creation handlers + const handleOauthFieldSubmit = (value: string) => { + const newFields = { ...oauthFields }; + + if (oauthSubStep === 'name') { + newFields.name = value; + setOauthFields(newFields); + setOauthSubStep('client-id'); + } else if (oauthSubStep === 'client-id') { + newFields.clientId = value; + setOauthFields(newFields); + setOauthSubStep('client-secret'); + } else if (oauthSubStep === 'client-secret') { + newFields.clientSecret = value; + setOauthFields(newFields); + setOauthSubStep('discovery-url'); + } else if (oauthSubStep === 'discovery-url') { + newFields.discoveryUrl = value; + setOauthFields(newFields); + + // Create the credential + void createIdentity({ + type: 'OAuthCredentialProvider', + name: newFields.name, + clientId: newFields.clientId, + clientSecret: newFields.clientSecret, + discoveryUrl: newFields.discoveryUrl, + }) + .then(result => { + if (result.ok) { + wizard.setOutboundAuth({ type: 'OAUTH', credentialName: newFields.name }); + } else { + setIsCreatingCredential(false); + setOauthSubStep('name'); + setOauthFields({ name: '', clientId: '', clientSecret: '', discoveryUrl: '' }); + } + }) + .catch(() => { + setIsCreatingCredential(false); + setOauthSubStep('name'); + setOauthFields({ name: '', clientId: '', clientSecret: '', discoveryUrl: '' }); + }); + } + }; + + const handleOauthFieldCancel = () => { + if (oauthSubStep === 'name') { + setIsCreatingCredential(false); + setOauthFields({ name: '', clientId: '', clientSecret: '', discoveryUrl: '' }); + } else if (oauthSubStep === 'client-id') { + setOauthSubStep('name'); + } else if (oauthSubStep === 'client-secret') { + setOauthSubStep('client-id'); + } else if (oauthSubStep === 'discovery-url') { + setOauthSubStep('client-secret'); + } + }; + + // API Key creation handlers + const handleApiKeyFieldSubmit = (value: string) => { + const newFields = { ...apiKeyFields }; + + if (apiKeySubStep === 'name') { + newFields.name = value; + setApiKeyFields(newFields); + setApiKeySubStep('api-key'); + } else if (apiKeySubStep === 'api-key') { + newFields.apiKey = value; + setApiKeyFields(newFields); + + void createIdentity({ + type: 'ApiKeyCredentialProvider', + name: newFields.name, + apiKey: newFields.apiKey, + }) + .then(result => { + if (result.ok) { + wizard.setOutboundAuth({ type: 'API_KEY', credentialName: newFields.name }); + } else { + setIsCreatingCredential(false); + setApiKeySubStep('name'); + setApiKeyFields({ name: '', apiKey: '' }); + } + }) + .catch(() => { + setIsCreatingCredential(false); + setApiKeySubStep('name'); + setApiKeyFields({ name: '', apiKey: '' }); + }); + } + }; + + const handleApiKeyFieldCancel = () => { + if (apiKeySubStep === 'name') { + setIsCreatingCredential(false); + setApiKeyFields({ name: '', apiKey: '' }); + } else if (apiKeySubStep === 'api-key') { + setApiKeySubStep('name'); + } + }; + const helpText = isConfirmStep ? HELP_TEXT.CONFIRM_CANCEL - : isTextStep + : isTextStep || isCreatingCredential ? HELP_TEXT.TEXT_INPUT : HELP_TEXT.NAVIGATE_SELECT; @@ -74,6 +253,107 @@ export function AddGatewayTargetScreen({ {noGatewaysAvailable && } + {isOutboundAuthStep && !outboundAuthType && ( + + )} + + {isOutboundAuthStep && + outboundAuthType && + outboundAuthType !== 'NONE' && + !credentialName && + !isCreatingCredential && ( + + )} + + {isOutboundAuthStep && isCreatingCredential && outboundAuthType === 'OAUTH' && ( + <> + {oauthSubStep === 'name' && ( + !existingCredentialNames.includes(value) || 'Credential name already exists'} + /> + )} + {oauthSubStep === 'client-id' && ( + value.trim().length > 0 || 'Client ID is required'} + /> + )} + {oauthSubStep === 'client-secret' && ( + value.trim().length > 0 || 'Client secret is required'} + revealChars={4} + /> + )} + {oauthSubStep === 'discovery-url' && ( + { + try { + const url = new URL(value); + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return 'Discovery URL must use http:// or https:// protocol'; + } + return true; + } catch { + return 'Must be a valid URL'; + } + }} + /> + )} + + )} + + {isOutboundAuthStep && isCreatingCredential && outboundAuthType === 'API_KEY' && ( + <> + {apiKeySubStep === 'name' && ( + !existingCredentialNames.includes(value) || 'Credential name already exists'} + /> + )} + {apiKeySubStep === 'api-key' && ( + value.trim().length > 0 || 'API key is required'} + revealChars={4} + /> + )} + + )} + {isTextStep && ( )} diff --git a/src/cli/tui/screens/mcp/types.ts b/src/cli/tui/screens/mcp/types.ts index 0c5e3689..fcf7d593 100644 --- a/src/cli/tui/screens/mcp/types.ts +++ b/src/cli/tui/screens/mcp/types.ts @@ -43,7 +43,15 @@ export type ComputeHost = 'Lambda' | 'AgentCoreRuntime'; * - host: Select compute host * - confirm: Review and confirm */ -export type AddGatewayTargetStep = 'name' | 'source' | 'endpoint' | 'language' | 'gateway' | 'host' | 'confirm'; +export type AddGatewayTargetStep = + | 'name' + | 'source' + | 'endpoint' + | 'language' + | 'gateway' + | 'host' + | 'outbound-auth' + | 'confirm'; export type TargetLanguage = 'Python' | 'TypeScript' | 'Other'; @@ -77,6 +85,7 @@ export const MCP_TOOL_STEP_LABELS: Record = { language: 'Language', gateway: 'Gateway', host: 'Host', + 'outbound-auth': 'Outbound Auth', confirm: 'Confirm', }; @@ -108,6 +117,11 @@ export const COMPUTE_HOST_OPTIONS = [ { id: 'AgentCoreRuntime', title: 'AgentCore Runtime', description: 'AgentCore Runtime (Python only)' }, ] as const; +export const OUTBOUND_AUTH_OPTIONS = [ + { id: 'NONE', title: 'No authorization', description: 'No outbound authentication' }, + { id: 'OAUTH', title: 'OAuth 2LO', description: 'OAuth 2.0 client credentials' }, +] as const; + export const PYTHON_VERSION_OPTIONS = [ { id: 'PYTHON_3_13', title: 'Python 3.13', description: 'Latest' }, { id: 'PYTHON_3_12', title: 'Python 3.12', description: '' }, diff --git a/src/cli/tui/screens/mcp/useAddGatewayTargetWizard.ts b/src/cli/tui/screens/mcp/useAddGatewayTargetWizard.ts index 9a60cc7d..71a96fe6 100644 --- a/src/cli/tui/screens/mcp/useAddGatewayTargetWizard.ts +++ b/src/cli/tui/screens/mcp/useAddGatewayTargetWizard.ts @@ -6,10 +6,10 @@ import { useCallback, useMemo, useState } from 'react'; /** * Steps for adding a gateway target (existing endpoint only). - * name → endpoint → gateway → confirm + * name → endpoint → gateway → outbound-auth → confirm */ function getSteps(): AddGatewayTargetStep[] { - return ['name', 'endpoint', 'gateway', 'confirm']; + return ['name', 'endpoint', 'gateway', 'outbound-auth', 'confirm']; } function deriveToolDefinition(name: string): ToolDefinition { @@ -68,11 +68,22 @@ export function useAddGatewayTargetWizard(existingGateways: string[] = []) { const setGateway = useCallback((gateway: string) => { setConfig(c => { const isSkipped = gateway === SKIP_FOR_NOW; - setStep('confirm'); return { ...c, gateway: isSkipped ? undefined : gateway }; }); + setStep('outbound-auth'); }, []); + const setOutboundAuth = useCallback( + (outboundAuth: { type: 'OAUTH' | 'API_KEY' | 'NONE'; credentialName?: string }) => { + setConfig(c => ({ + ...c, + outboundAuth, + })); + setStep('confirm'); + }, + [] + ); + const reset = useCallback(() => { setConfig(getDefaultConfig()); setStep('name'); @@ -88,6 +99,7 @@ export function useAddGatewayTargetWizard(existingGateways: string[] = []) { setName, setEndpoint, setGateway, + setOutboundAuth, reset, }; } From 2681cea2fc0fdc4f4f743bc9fdacd970dccca553 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath <42380254+tejaskash@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:33:37 -0500 Subject: [PATCH 23/28] fix: require gateway when creating a gateway target (#469) * fix: require gateway when creating a gateway target A gateway target must always be attached to a gateway. Previously it was possible to create unassigned targets via "Skip for now" in the TUI or by omitting --gateway in non-interactive mode. - Validation now requires --gateway and verifies the gateway exists - TUI removes the "Skip for now" option from gateway selection - createExternalGatewayTarget rejects missing gateway at operations layer - Updated tests to cover all new validation paths * style: fix prettier formatting --- .../commands/add/__tests__/validate.test.ts | 67 +++++++++++++++---- src/cli/commands/add/validate.ts | 26 ++++++- .../mcp/__tests__/create-mcp.test.ts | 39 ++--------- src/cli/operations/mcp/create-mcp.ts | 42 +++++------- .../screens/mcp/AddGatewayTargetScreen.tsx | 10 +-- .../screens/mcp/useAddGatewayTargetWizard.ts | 6 +- 6 files changed, 104 insertions(+), 86 deletions(-) diff --git a/src/cli/commands/add/__tests__/validate.test.ts b/src/cli/commands/add/__tests__/validate.test.ts index 40f5dfec..3b319c9e 100644 --- a/src/cli/commands/add/__tests__/validate.test.ts +++ b/src/cli/commands/add/__tests__/validate.test.ts @@ -12,9 +12,10 @@ import { validateAddIdentityOptions, validateAddMemoryOptions, } from '../validate.js'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const mockReadProjectSpec = vi.fn(); +const mockGetExistingGateways = vi.fn(); vi.mock('../../../../lib/index.js', () => ({ ConfigIO: class { @@ -22,6 +23,10 @@ vi.mock('../../../../lib/index.js', () => ({ }, })); +vi.mock('../../../operations/mcp/create-mcp.js', () => ({ + getExistingGateways: (...args: unknown[]) => mockGetExistingGateways(...args), +})); + // Helper: valid base options for each type const validAgentOptionsByo: AddAgentOptions = { name: 'TestAgent', @@ -238,19 +243,48 @@ describe('validate', () => { }); describe('validateAddGatewayTargetOptions', () => { + beforeEach(() => { + // By default, mock that the gateway from validGatewayTargetOptions exists + mockGetExistingGateways.mockResolvedValue(['my-gateway']); + }); + // AC15: Required fields validated - it('returns error for missing required fields', async () => { - const requiredFields: { field: keyof AddGatewayTargetOptions; error: string }[] = [ - { field: 'name', error: '--name is required' }, - { field: 'language', error: '--language is required' }, - ]; + it('returns error for missing name', async () => { + const opts = { ...validGatewayTargetOptions, name: undefined }; + const result = await validateAddGatewayTargetOptions(opts); + expect(result.valid).toBe(false); + expect(result.error).toBe('--name is required'); + }); - for (const { field, error } of requiredFields) { - const opts = { ...validGatewayTargetOptions, [field]: undefined }; - const result = await validateAddGatewayTargetOptions(opts); - expect(result.valid, `Should fail for missing ${String(field)}`).toBe(false); - expect(result.error).toBe(error); - } + it('returns error for missing language (non-existing-endpoint)', async () => { + const opts = { ...validGatewayTargetOptions, language: undefined }; + const result = await validateAddGatewayTargetOptions(opts); + expect(result.valid).toBe(false); + expect(result.error).toBe('--language is required'); + }); + + // Gateway is required + it('returns error when --gateway is missing', async () => { + const opts = { ...validGatewayTargetOptions, gateway: undefined }; + const result = await validateAddGatewayTargetOptions(opts); + expect(result.valid).toBe(false); + expect(result.error).toContain('--gateway is required'); + }); + + it('returns error when no gateways exist', async () => { + mockGetExistingGateways.mockResolvedValue([]); + const result = await validateAddGatewayTargetOptions(validGatewayTargetOptions); + expect(result.valid).toBe(false); + expect(result.error).toContain('No gateways found'); + expect(result.error).toContain('agentcore add gateway'); + }); + + it('returns error when specified gateway does not exist', async () => { + mockGetExistingGateways.mockResolvedValue(['other-gateway']); + const result = await validateAddGatewayTargetOptions(validGatewayTargetOptions); + expect(result.valid).toBe(false); + expect(result.error).toContain('Gateway "my-gateway" not found'); + expect(result.error).toContain('other-gateway'); }); // AC16: Invalid values rejected @@ -274,6 +308,7 @@ describe('validate', () => { name: 'test-tool', source: 'existing-endpoint', endpoint: 'https://example.com/mcp', + gateway: 'my-gateway', }; const result = await validateAddGatewayTargetOptions(options); expect(result.valid).toBe(true); @@ -285,6 +320,7 @@ describe('validate', () => { name: 'test-tool', source: 'existing-endpoint', endpoint: 'http://localhost:3000/mcp', + gateway: 'my-gateway', }; const result = await validateAddGatewayTargetOptions(options); expect(result.valid).toBe(true); @@ -294,6 +330,7 @@ describe('validate', () => { const options: AddGatewayTargetOptions = { name: 'test-tool', source: 'existing-endpoint', + gateway: 'my-gateway', }; const result = await validateAddGatewayTargetOptions(options); expect(result.valid).toBe(false); @@ -305,6 +342,7 @@ describe('validate', () => { name: 'test-tool', source: 'existing-endpoint', endpoint: 'ftp://example.com/mcp', + gateway: 'my-gateway', }; const result = await validateAddGatewayTargetOptions(options); expect(result.valid).toBe(false); @@ -316,6 +354,7 @@ describe('validate', () => { name: 'test-tool', source: 'existing-endpoint', endpoint: 'not-a-url', + gateway: 'my-gateway', }; const result = await validateAddGatewayTargetOptions(options); expect(result.valid).toBe(false); @@ -331,6 +370,7 @@ describe('validate', () => { const options: AddGatewayTargetOptions = { name: 'test-tool', language: 'Python', + gateway: 'my-gateway', outboundAuthType: 'API_KEY', credentialName: 'missing-cred', }; @@ -347,6 +387,7 @@ describe('validate', () => { const options: AddGatewayTargetOptions = { name: 'test-tool', language: 'Python', + gateway: 'my-gateway', outboundAuthType: 'API_KEY', credentialName: 'any-cred', }; @@ -363,6 +404,7 @@ describe('validate', () => { const options: AddGatewayTargetOptions = { name: 'test-tool', language: 'Python', + gateway: 'my-gateway', outboundAuthType: 'API_KEY', credentialName: 'valid-cred', }; @@ -429,6 +471,7 @@ describe('validate', () => { source: 'existing-endpoint', endpoint: 'https://example.com/mcp', host: 'Lambda', + gateway: 'my-gateway', }; const result = await validateAddGatewayTargetOptions(options); expect(result.valid).toBe(false); diff --git a/src/cli/commands/add/validate.ts b/src/cli/commands/add/validate.ts index b11e2aa7..9a4bc4df 100644 --- a/src/cli/commands/add/validate.ts +++ b/src/cli/commands/add/validate.ts @@ -8,6 +8,7 @@ import { TargetLanguageSchema, getSupportedModelProviders, } from '../../../schema'; +import { getExistingGateways } from '../../operations/mcp/create-mcp'; import type { AddAgentOptions, AddGatewayOptions, @@ -197,6 +198,30 @@ export async function validateAddGatewayTargetOptions(options: AddGatewayTargetO return { valid: false, error: 'Invalid source. Valid options: existing-endpoint, create-new' }; } + // Gateway is required — a gateway target must be attached to a gateway + if (!options.gateway) { + return { + valid: false, + error: + "--gateway is required. A gateway target must be attached to a gateway. Create a gateway first with 'agentcore add gateway'.", + }; + } + + // Validate the specified gateway exists + const existingGateways = await getExistingGateways(); + if (existingGateways.length === 0) { + return { + valid: false, + error: "No gateways found. Create a gateway first with 'agentcore add gateway' before adding a gateway target.", + }; + } + if (!existingGateways.includes(options.gateway)) { + return { + valid: false, + error: `Gateway "${options.gateway}" not found. Available gateways: ${existingGateways.join(', ')}`, + }; + } + if (options.source === 'existing-endpoint') { if (options.host) { return { valid: false, error: '--host is not applicable for existing endpoint targets' }; @@ -216,7 +241,6 @@ export async function validateAddGatewayTargetOptions(options: AddGatewayTargetO // Populate defaults for fields skipped by external endpoint flow options.language ??= 'Other'; - options.gateway ??= undefined; return { valid: true }; } diff --git a/src/cli/operations/mcp/__tests__/create-mcp.test.ts b/src/cli/operations/mcp/__tests__/create-mcp.test.ts index 398e8f32..25e5f173 100644 --- a/src/cli/operations/mcp/__tests__/create-mcp.test.ts +++ b/src/cli/operations/mcp/__tests__/create-mcp.test.ts @@ -1,4 +1,3 @@ -import { SKIP_FOR_NOW } from '../../../tui/screens/mcp/types.js'; import type { AddGatewayConfig, AddGatewayTargetConfig } from '../../../tui/screens/mcp/types.js'; import { createExternalGatewayTarget, createGatewayFromWizard, getUnassignedTargets } from '../create-mcp.js'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -55,29 +54,14 @@ describe('createExternalGatewayTarget', () => { expect(gateway.targets[0]!.targetType).toBe('mcpServer'); }); - it('stores target in unassignedTargets when gateway is skip-for-now', async () => { - const mockMcpSpec = { agentCoreGateways: [] }; - mockConfigExists.mockReturnValue(true); - mockReadMcpSpec.mockResolvedValue(mockMcpSpec); - - await createExternalGatewayTarget(makeExternalConfig({ gateway: SKIP_FOR_NOW })); - - expect(mockWriteMcpSpec).toHaveBeenCalled(); - const written = mockWriteMcpSpec.mock.calls[0]![0]; - expect(written.unassignedTargets).toHaveLength(1); - expect(written.unassignedTargets[0]!.name).toBe('test-target'); - expect(written.unassignedTargets[0]!.endpoint).toBe('https://api.example.com'); - }); - - it('initializes unassignedTargets array if it does not exist in mcp spec', async () => { - const mockMcpSpec = { agentCoreGateways: [] }; + it('throws when gateway is not provided', async () => { + const mockMcpSpec = { agentCoreGateways: [{ name: 'test-gateway', targets: [] }] }; mockConfigExists.mockReturnValue(true); mockReadMcpSpec.mockResolvedValue(mockMcpSpec); - await createExternalGatewayTarget(makeExternalConfig({ gateway: SKIP_FOR_NOW })); - - const written = mockWriteMcpSpec.mock.calls[0]![0]; - expect(Array.isArray(written.unassignedTargets)).toBe(true); + await expect(createExternalGatewayTarget(makeExternalConfig({ gateway: undefined }))).rejects.toThrow( + 'Gateway is required' + ); }); it('throws on duplicate target name in gateway', async () => { @@ -92,19 +76,6 @@ describe('createExternalGatewayTarget', () => { ); }); - it('throws on duplicate target name in unassigned targets', async () => { - const mockMcpSpec = { - agentCoreGateways: [], - unassignedTargets: [{ name: 'test-target' }], - }; - mockConfigExists.mockReturnValue(true); - mockReadMcpSpec.mockResolvedValue(mockMcpSpec); - - await expect(createExternalGatewayTarget(makeExternalConfig({ gateway: SKIP_FOR_NOW }))).rejects.toThrow( - 'Unassigned target "test-target" already exists' - ); - }); - it('throws when gateway not found', async () => { const mockMcpSpec = { agentCoreGateways: [] }; mockConfigExists.mockReturnValue(true); diff --git a/src/cli/operations/mcp/create-mcp.ts b/src/cli/operations/mcp/create-mcp.ts index aefd731c..1f554642 100644 --- a/src/cli/operations/mcp/create-mcp.ts +++ b/src/cli/operations/mcp/create-mcp.ts @@ -10,12 +10,7 @@ import type { import { AgentCoreCliMcpDefsSchema, ToolDefinitionSchema } from '../../../schema'; import { getTemplateToolDefinitions, renderGatewayTargetTemplate } from '../../templates/GatewayTargetRenderer'; import type { AddGatewayConfig, AddGatewayTargetConfig } from '../../tui/screens/mcp/types'; -import { - DEFAULT_HANDLER, - DEFAULT_NODE_VERSION, - DEFAULT_PYTHON_VERSION, - SKIP_FOR_NOW, -} from '../../tui/screens/mcp/types'; +import { DEFAULT_HANDLER, DEFAULT_NODE_VERSION, DEFAULT_PYTHON_VERSION } from '../../tui/screens/mcp/types'; import { existsSync } from 'fs'; import { mkdir, readFile, writeFile } from 'fs/promises'; import { dirname, join } from 'path'; @@ -257,31 +252,24 @@ export async function createExternalGatewayTarget(config: AddGatewayTargetConfig ...(config.outboundAuth && { outboundAuth: config.outboundAuth }), }; - if (config.gateway && config.gateway !== SKIP_FOR_NOW) { - // Assign to specific gateway - const gateway = mcpSpec.agentCoreGateways.find(g => g.name === config.gateway); - if (!gateway) { - throw new Error(`Gateway "${config.gateway}" not found.`); - } - - // Check for duplicate target name - if (gateway.targets.some(t => t.name === config.name)) { - throw new Error(`Target "${config.name}" already exists in gateway "${gateway.name}".`); - } - - gateway.targets.push(target); - } else { - // Add to unassigned targets - mcpSpec.unassignedTargets ??= []; + if (!config.gateway) { + throw new Error( + "Gateway is required. A gateway target must be attached to a gateway. Create a gateway first with 'agentcore add gateway'." + ); + } - // Check for duplicate target name in unassigned targets - if (mcpSpec.unassignedTargets.some((t: AgentCoreGatewayTarget) => t.name === config.name)) { - throw new Error(`Unassigned target "${config.name}" already exists.`); - } + const gateway = mcpSpec.agentCoreGateways.find(g => g.name === config.gateway); + if (!gateway) { + throw new Error(`Gateway "${config.gateway}" not found.`); + } - mcpSpec.unassignedTargets.push(target); + // Check for duplicate target name + if (gateway.targets.some(t => t.name === config.name)) { + throw new Error(`Target "${config.name}" already exists in gateway "${gateway.name}".`); } + gateway.targets.push(target); + await configIO.writeMcpSpec(mcpSpec); return { mcpDefsPath: '', toolName: config.name, projectPath: '' }; diff --git a/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx b/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx index 30ece187..1dd507ae 100644 --- a/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx +++ b/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx @@ -6,7 +6,7 @@ import { useListNavigation } from '../../hooks'; import { generateUniqueName } from '../../utils'; import { useCreateIdentity, useExistingCredentialNames } from '../identity/useCreateIdentity.js'; import type { AddGatewayTargetConfig } from './types'; -import { MCP_TOOL_STEP_LABELS, OUTBOUND_AUTH_OPTIONS, SKIP_FOR_NOW } from './types'; +import { MCP_TOOL_STEP_LABELS, OUTBOUND_AUTH_OPTIONS } from './types'; import { useAddGatewayTargetWizard } from './useAddGatewayTargetWizard'; import { Box, Text } from 'ink'; import React, { useMemo, useState } from 'react'; @@ -38,10 +38,7 @@ export function AddGatewayTargetScreen({ const [apiKeyFields, setApiKeyFields] = useState({ name: '', apiKey: '' }); const gatewayItems: SelectableItem[] = useMemo( - () => [ - ...existingGateways.map(g => ({ id: g, title: g })), - { id: SKIP_FOR_NOW, title: 'Skip for now', description: 'Create unassigned target' }, - ], + () => existingGateways.map(g => ({ id: g, title: g })), [existingGateways] ); @@ -388,8 +385,7 @@ export function AddGatewayTargetScreen({ fields={[ { label: 'Name', value: wizard.config.name }, ...(wizard.config.endpoint ? [{ label: 'Endpoint', value: wizard.config.endpoint }] : []), - ...(wizard.config.gateway ? [{ label: 'Gateway', value: wizard.config.gateway }] : []), - ...(!wizard.config.gateway ? [{ label: 'Gateway', value: '(none - assign later)' }] : []), + { label: 'Gateway', value: wizard.config.gateway ?? '' }, ...(wizard.config.outboundAuth ? [ { label: 'Auth Type', value: wizard.config.outboundAuth.type }, diff --git a/src/cli/tui/screens/mcp/useAddGatewayTargetWizard.ts b/src/cli/tui/screens/mcp/useAddGatewayTargetWizard.ts index 71a96fe6..d29daaf6 100644 --- a/src/cli/tui/screens/mcp/useAddGatewayTargetWizard.ts +++ b/src/cli/tui/screens/mcp/useAddGatewayTargetWizard.ts @@ -1,7 +1,6 @@ import { APP_DIR, MCP_APP_SUBDIR } from '../../../../lib'; import type { ToolDefinition } from '../../../../schema'; import type { AddGatewayTargetConfig, AddGatewayTargetStep } from './types'; -import { SKIP_FOR_NOW } from './types'; import { useCallback, useMemo, useState } from 'react'; /** @@ -66,10 +65,7 @@ export function useAddGatewayTargetWizard(existingGateways: string[] = []) { }, []); const setGateway = useCallback((gateway: string) => { - setConfig(c => { - const isSkipped = gateway === SKIP_FOR_NOW; - return { ...c, gateway: isSkipped ? undefined : gateway }; - }); + setConfig(c => ({ ...c, gateway })); setStep('outbound-auth'); }, []); From 08a9fa0beed31a113b4f75eb074a818d78636bb4 Mon Sep 17 00:00:00 2001 From: Aidan Daly <99039782+aidandaly24@users.noreply.github.com> Date: Mon, 2 Mar 2026 11:45:48 -0500 Subject: [PATCH 24/28] fix: correct gateway success message to reference gateway-target command (#468) --- src/cli/tui/screens/mcp/AddGatewayFlow.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/tui/screens/mcp/AddGatewayFlow.tsx b/src/cli/tui/screens/mcp/AddGatewayFlow.tsx index 0f99cc83..a285a4bf 100644 --- a/src/cli/tui/screens/mcp/AddGatewayFlow.tsx +++ b/src/cli/tui/screens/mcp/AddGatewayFlow.tsx @@ -73,7 +73,7 @@ export function AddGatewayFlow({ isInteractive = true, onExit, onBack, onDev, on Date: Mon, 2 Mar 2026 13:18:36 -0500 Subject: [PATCH 25/28] feat: add OAuth credential type to add identity TUI wizard (#464) * feat: add OAuth credential type to add identity TUI wizard * fix: add OIDC well-known suffix validation to identity TUI discovery URL * refactor: reuse identity screen for OAuth credential creation in gateway target flow * fix: correct deploy step ordering and credential count display * fix: allow identity creation without existing agents * fix: rename MCP gateway references to gateway in UI text * fix: add missing newline to identity types --- src/cli/commands/add/command.tsx | 2 +- .../operations/identity/create-identity.ts | 13 + .../tui/components/CredentialSourcePrompt.tsx | 11 +- src/cli/tui/hooks/useCdkPreflight.ts | 295 +++++++++++++----- src/cli/tui/screens/add/AddFlow.tsx | 4 - src/cli/tui/screens/add/AddScreen.tsx | 4 +- .../tui/screens/identity/AddIdentityFlow.tsx | 27 +- .../screens/identity/AddIdentityScreen.tsx | 95 +++++- src/cli/tui/screens/identity/types.ts | 21 +- .../screens/identity/useAddIdentityWizard.ts | 112 +++++-- .../tui/screens/identity/useCreateIdentity.ts | 16 + .../tui/screens/mcp/AddGatewayTargetFlow.tsx | 68 +++- .../screens/mcp/AddGatewayTargetScreen.tsx | 263 ++-------------- src/cli/tui/screens/remove/RemoveScreen.tsx | 2 +- .../tui/screens/schema/EditSchemaScreen.tsx | 2 +- .../tui/screens/schema/McpGuidedEditor.tsx | 2 +- 16 files changed, 559 insertions(+), 378 deletions(-) diff --git a/src/cli/commands/add/command.tsx b/src/cli/commands/add/command.tsx index ebc0ff44..6a9370bb 100644 --- a/src/cli/commands/add/command.tsx +++ b/src/cli/commands/add/command.tsx @@ -265,7 +265,7 @@ export function registerAdd(program: Command) { // Subcommand: add gateway addCmd .command('gateway') - .description('Add an MCP gateway to the project') + .description('Add a gateway to the project') .option('--name ', 'Gateway name') .option('--description ', 'Gateway description') .option('--authorizer-type ', 'Authorizer type: NONE or CUSTOM_JWT', 'NONE') diff --git a/src/cli/operations/identity/create-identity.ts b/src/cli/operations/identity/create-identity.ts index 6c6705bb..f42bee61 100644 --- a/src/cli/operations/identity/create-identity.ts +++ b/src/cli/operations/identity/create-identity.ts @@ -108,6 +108,19 @@ export async function getAllCredentialNames(): Promise { } } +/** + * Get list of existing credentials with full type information from the project. + */ +export async function getAllCredentials(): Promise { + try { + const configIO = new ConfigIO(); + const project = await configIO.readProjectSpec(); + return project.credentials; + } catch { + return []; + } +} + /** * Create a credential resource and add it to the project. * Writes the credential config to agentcore.json and secrets to .env.local. diff --git a/src/cli/tui/components/CredentialSourcePrompt.tsx b/src/cli/tui/components/CredentialSourcePrompt.tsx index f672a507..1e284766 100644 --- a/src/cli/tui/components/CredentialSourcePrompt.tsx +++ b/src/cli/tui/components/CredentialSourcePrompt.tsx @@ -137,17 +137,18 @@ export function CredentialSourcePrompt({ Identity Provider Setup - {missingCredentials.length} identity provider{missingCredentials.length > 1 ? 's' : ''} configured: + {new Set(missingCredentials.map(c => c.providerName)).size} identity provider + {new Set(missingCredentials.map(c => c.providerName)).size > 1 ? 's' : ''} configured: - {missingCredentials.map(cred => ( - - • {cred.providerName} + {[...new Set(missingCredentials.map(c => c.providerName))].map(name => ( + + • {name} ))} - How would you like to provide the API keys? + How would you like to provide the credentials? diff --git a/src/cli/tui/hooks/useCdkPreflight.ts b/src/cli/tui/hooks/useCdkPreflight.ts index c669adaa..89ed181e 100644 --- a/src/cli/tui/hooks/useCdkPreflight.ts +++ b/src/cli/tui/hooks/useCdkPreflight.ts @@ -91,9 +91,8 @@ export interface PreflightResult { const STEP_VALIDATE = 0; const STEP_DEPS = 1; const STEP_BUILD = 2; -const STEP_SYNTH = 3; -const STEP_STACK_STATUS = 4; -// Note: Identity and Bootstrap steps are dynamically appended, use steps.length - 1 to find them +// Note: Identity steps are inserted at index 3+ when needed, shifting synth and stack status down. +// Use findStepIndex() to locate synth and stack status dynamically. const BASE_PREFLIGHT_STEPS: Step[] = [ { label: 'Validate project', status: 'pending' }, @@ -103,7 +102,12 @@ const BASE_PREFLIGHT_STEPS: Step[] = [ { label: 'Check stack status', status: 'pending' }, ]; -const IDENTITY_STEP: Step = { label: 'Set up API key providers', status: 'pending' }; +const LABEL_SYNTH = 'Synthesize CloudFormation'; +const LABEL_STACK_STATUS = 'Check stack status'; +const LABEL_API_KEY = 'Set up API key providers'; +const LABEL_OAUTH = 'Set up OAuth providers'; + +const IDENTITY_STEP: Step = { label: LABEL_API_KEY, status: 'pending' }; const BOOTSTRAP_STEP: Step = { label: 'Bootstrap AWS environment', status: 'pending' }; export function useCdkPreflight(options: PreflightOptions): PreflightResult { @@ -138,6 +142,10 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { setSteps(prev => prev.map((s, i) => (i === index ? { ...s, ...update } : s))); }; + const updateStepByLabel = (label: string, update: Partial) => { + setSteps(prev => prev.map(s => (s.label === label ? { ...s, ...update } : s))); + }; + const resetSteps = () => { setSteps(BASE_PREFLIGHT_STEPS.map(s => ({ ...s, status: 'pending' as const }))); }; @@ -380,7 +388,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { } // Step: Synthesize CloudFormation - updateStep(STEP_SYNTH, { status: 'running' }); + updateStepByLabel(LABEL_SYNTH, { status: 'running' }); logger.startStep('Synthesize CloudFormation'); let synthStackNames: string[]; try { @@ -394,14 +402,17 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { synthStackNames = synthResult.stackNames; logger.log(`Stacks: ${synthResult.stackNames.join(', ')}`); logger.endStep('success'); - updateStep(STEP_SYNTH, { status: 'success' }); + updateStepByLabel(LABEL_SYNTH, { status: 'success' }); } catch (err) { const errorMsg = formatError(err); logger.endStep('error', errorMsg); if (isExpiredTokenError(err)) { setHasTokenExpiredError(true); } - updateStep(STEP_SYNTH, { status: 'error', error: logger.getFailureMessage('Synthesize CloudFormation') }); + updateStepByLabel(LABEL_SYNTH, { + status: 'error', + error: logger.getFailureMessage('Synthesize CloudFormation'), + }); setPhase('error'); isRunningRef.current = false; return; @@ -410,34 +421,37 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { // Step: Check stack status (ensure stacks are not in UPDATE_IN_PROGRESS etc.) const target = preflightContext.awsTargets[0]; if (target && synthStackNames.length > 0) { - updateStep(STEP_STACK_STATUS, { status: 'running' }); + updateStepByLabel(LABEL_STACK_STATUS, { status: 'running' }); logger.startStep('Check stack status'); try { const stackStatus = await checkStackDeployability(target.region, synthStackNames); if (!stackStatus.canDeploy) { const errorMsg = stackStatus.message ?? `Stack ${stackStatus.blockingStack} is not in a deployable state`; logger.endStep('error', errorMsg); - updateStep(STEP_STACK_STATUS, { status: 'error', error: errorMsg }); + updateStepByLabel(LABEL_STACK_STATUS, { status: 'error', error: errorMsg }); setPhase('error'); isRunningRef.current = false; return; } logger.endStep('success'); - updateStep(STEP_STACK_STATUS, { status: 'success' }); + updateStepByLabel(LABEL_STACK_STATUS, { status: 'success' }); } catch (err) { const errorMsg = formatError(err); logger.endStep('error', errorMsg); if (isExpiredTokenError(err)) { setHasTokenExpiredError(true); } - updateStep(STEP_STACK_STATUS, { status: 'error', error: logger.getFailureMessage('Check stack status') }); + updateStepByLabel(LABEL_STACK_STATUS, { + status: 'error', + error: logger.getFailureMessage('Check stack status'), + }); setPhase('error'); isRunningRef.current = false; return; } } else { // Skip stack status check if no target or no stacks - updateStep(STEP_STACK_STATUS, { status: 'success' }); + updateStepByLabel(LABEL_STACK_STATUS, { status: 'success' }); } // Check if bootstrap is needed @@ -488,16 +502,78 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { isRunningRef.current = true; const runIdentitySetup = async () => { - // If user chose to skip, go directly to bootstrap check + // If user chose to skip, go directly to synth if (skipIdentitySetup) { - logger.log('Skipping API key provider setup (user choice)'); + logger.log('Skipping identity provider setup (user choice)'); setSkipIdentitySetup(false); // Reset for next run + // Synthesize CloudFormation + updateStepByLabel(LABEL_SYNTH, { status: 'running' }); + logger.startStep('Synthesize CloudFormation'); + let synthStackNames: string[]; + try { + const synthResult = await synthesizeCdk(context.cdkProject, { + ioHost: switchableIoHost.ioHost, + previousWrapper: wrapperRef.current, + }); + wrapperRef.current = synthResult.toolkitWrapper; + setCdkToolkitWrapper(synthResult.toolkitWrapper); + setStackNames(synthResult.stackNames); + synthStackNames = synthResult.stackNames; + logger.endStep('success'); + updateStepByLabel(LABEL_SYNTH, { status: 'success' }); + } catch (err) { + const errorMsg = formatError(err); + logger.endStep('error', errorMsg); + updateStepByLabel(LABEL_SYNTH, { + status: 'error', + error: logger.getFailureMessage('Synthesize CloudFormation'), + }); + setPhase('error'); + isRunningRef.current = false; + return; + } + + // Check stack status + const target = context.awsTargets[0]; + if (target && synthStackNames.length > 0) { + updateStepByLabel(LABEL_STACK_STATUS, { status: 'running' }); + logger.startStep('Check stack status'); + try { + const stackStatus = await checkStackDeployability(target.region, synthStackNames); + if (!stackStatus.canDeploy) { + const errorMsg = stackStatus.message ?? `Stack ${stackStatus.blockingStack} is not in a deployable state`; + logger.endStep('error', errorMsg); + updateStepByLabel(LABEL_STACK_STATUS, { status: 'error', error: errorMsg }); + setPhase('error'); + isRunningRef.current = false; + return; + } + logger.endStep('success'); + updateStepByLabel(LABEL_STACK_STATUS, { status: 'success' }); + } catch (err) { + const errorMsg = formatError(err); + logger.endStep('error', errorMsg); + if (isExpiredTokenError(err)) { + setHasTokenExpiredError(true); + } + updateStepByLabel(LABEL_STACK_STATUS, { + status: 'error', + error: logger.getFailureMessage('Check stack status'), + }); + setPhase('error'); + isRunningRef.current = false; + return; + } + } else { + updateStepByLabel(LABEL_STACK_STATUS, { status: 'success' }); + } + // Check if bootstrap is needed const bootstrapCheck = await checkBootstrapNeeded(context.awsTargets); if (bootstrapCheck.needsBootstrap && bootstrapCheck.target) { setBootstrapContext({ - toolkitWrapper: wrapperRef.current!, + toolkitWrapper: wrapperRef.current, target: bootstrapCheck.target, }); setPhase('bootstrap-confirm'); @@ -510,15 +586,30 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { } // Run identity setup with runtime credentials - setSteps(prev => [...prev, { ...IDENTITY_STEP, status: 'running' }]); - logger.startStep('Set up API key providers'); + // Insert identity steps before synthesize in the step list + const hasApiKeys = hasIdentityApiProviders(context.projectSpec); + const hasOAuth = hasIdentityOAuthProviders(context.projectSpec); + setSteps(prev => { + const synthIndex = prev.findIndex(s => s.label === LABEL_SYNTH); + const identitySteps: Step[] = []; + if (hasApiKeys) identitySteps.push({ ...IDENTITY_STEP, status: 'running' }); + if (hasOAuth) identitySteps.push({ label: LABEL_OAUTH, status: hasApiKeys ? 'pending' : 'running' }); + return [...prev.slice(0, synthIndex), ...identitySteps, ...prev.slice(synthIndex)]; + }); + + if (hasApiKeys) { + logger.startStep('Set up API key providers'); + } const target = context.awsTargets[0]; if (!target) { - logger.endStep('error', 'No AWS target configured'); - setSteps(prev => - prev.map((s, i) => (i === prev.length - 1 ? { ...s, status: 'error', error: 'No AWS target configured' } : s)) - ); + const errorMsg = 'No AWS target configured'; + if (hasApiKeys) { + logger.endStep('error', errorMsg); + updateStepByLabel(LABEL_API_KEY, { status: 'error', error: errorMsg }); + } else if (hasOAuth) { + updateStepByLabel(LABEL_OAUTH, { status: 'error', error: errorMsg }); + } setPhase('error'); isRunningRef.current = false; return; @@ -526,66 +617,69 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { try { const configBaseDir = path.dirname(context.cdkProject.projectDir); - const identityResult = await setupApiKeyProviders({ - projectSpec: context.projectSpec, - configBaseDir, - region: target.region, - runtimeCredentials: runtimeCredentials ?? undefined, - enableKmsEncryption: true, - }); - // Log KMS setup - if (identityResult.kmsKeyArn) { - logger.log(`Token vault encrypted with KMS key: ${identityResult.kmsKeyArn}`); - setIdentityKmsKeyArn(identityResult.kmsKeyArn); - } + // Collect credential ARNs for deployed state + const deployedCredentials: Record< + string, + { credentialProviderArn: string; clientSecretArn?: string; callbackUrl?: string } + > = {}; + let kmsKeyArn: string | undefined; - // Log results - for (const result of identityResult.results) { - if (result.status === 'created') { - logger.log(`Created API key provider: ${result.providerName}`); - } else if (result.status === 'updated') { - logger.log(`Updated API key provider: ${result.providerName}`); - } else if (result.status === 'exists') { - logger.log(`API key provider exists: ${result.providerName}`); - } else if (result.status === 'skipped') { - logger.log(`Skipped ${result.providerName}: ${result.error}`); - } else if (result.status === 'error') { - logger.log(`Error for ${result.providerName}: ${result.error}`); + // Set up API key providers if needed + if (hasApiKeys) { + const identityResult = await setupApiKeyProviders({ + projectSpec: context.projectSpec, + configBaseDir, + region: target.region, + runtimeCredentials: runtimeCredentials ?? undefined, + enableKmsEncryption: true, + }); + + // Log KMS setup + if (identityResult.kmsKeyArn) { + logger.log(`Token vault encrypted with KMS key: ${identityResult.kmsKeyArn}`); + kmsKeyArn = identityResult.kmsKeyArn; + setIdentityKmsKeyArn(identityResult.kmsKeyArn); } - } - if (identityResult.hasErrors) { - logger.endStep('error', 'Some API key providers failed to set up'); - setSteps(prev => - prev.map((s, i) => - i === prev.length - 1 ? { ...s, status: 'error', error: 'Some API key providers failed' } : s - ) - ); - setPhase('error'); - isRunningRef.current = false; - return; - } + // Log results + for (const result of identityResult.results) { + if (result.status === 'created') { + logger.log(`Created API key provider: ${result.providerName}`); + } else if (result.status === 'updated') { + logger.log(`Updated API key provider: ${result.providerName}`); + } else if (result.status === 'exists') { + logger.log(`API key provider exists: ${result.providerName}`); + } else if (result.status === 'skipped') { + logger.log(`Skipped ${result.providerName}: ${result.error}`); + } else if (result.status === 'error') { + logger.log(`Error for ${result.providerName}: ${result.error}`); + } + } - logger.endStep('success'); - setSteps(prev => prev.map((s, i) => (i === prev.length - 1 ? { ...s, status: 'success' } : s))); + if (identityResult.hasErrors) { + logger.endStep('error', 'Some API key providers failed to set up'); + updateStepByLabel(LABEL_API_KEY, { status: 'error', error: 'Some API key providers failed' }); + setPhase('error'); + isRunningRef.current = false; + return; + } - // Collect API Key credential ARNs for deployed state - const deployedCredentials: Record< - string, - { credentialProviderArn: string; clientSecretArn?: string; callbackUrl?: string } - > = {}; - for (const result of identityResult.results) { - if (result.credentialProviderArn) { - deployedCredentials[result.providerName] = { - credentialProviderArn: result.credentialProviderArn, - }; + logger.endStep('success'); + updateStepByLabel(LABEL_API_KEY, { status: 'success' }); + + for (const result of identityResult.results) { + if (result.credentialProviderArn) { + deployedCredentials[result.providerName] = { + credentialProviderArn: result.credentialProviderArn, + }; + } } } // Set up OAuth credential providers if needed - if (hasIdentityOAuthProviders(context.projectSpec)) { - setSteps(prev => [...prev, { label: 'Set up OAuth providers', status: 'running' }]); + if (hasOAuth) { + updateStepByLabel(LABEL_OAUTH, { status: 'running' }); logger.startStep('Set up OAuth providers'); const oauthResult = await setupOAuth2Providers({ @@ -609,11 +703,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { if (oauthResult.hasErrors) { logger.endStep('error', 'Some OAuth providers failed to set up'); - setSteps(prev => - prev.map((s, i) => - i === prev.length - 1 ? { ...s, status: 'error', error: 'Some OAuth providers failed' } : s - ) - ); + updateStepByLabel(LABEL_OAUTH, { status: 'error', error: 'Some OAuth providers failed' }); setPhase('error'); isRunningRef.current = false; return; @@ -637,7 +727,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { Object.assign(deployedCredentials, creds); logger.endStep('success'); - setSteps(prev => prev.map((s, i) => (i === prev.length - 1 ? { ...s, status: 'success' } : s))); + updateStepByLabel(LABEL_OAUTH, { status: 'success' }); } // Write partial deployed state with credential ARNs before CDK synth @@ -648,7 +738,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { const targetState = existingState.targets?.[target!.name] ?? { resources: {} }; targetState.resources ??= {}; targetState.resources.credentials = deployedCredentials; - if (identityResult.kmsKeyArn) targetState.resources.identityKmsKeyArn = identityResult.kmsKeyArn; + if (kmsKeyArn) targetState.resources.identityKmsKeyArn = kmsKeyArn; await configIO.writeDeployedState({ ...existingState, targets: { ...existingState.targets, [target!.name]: targetState }, @@ -658,9 +748,10 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { // Clear runtime credentials setRuntimeCredentials(null); - // Re-synth now that credentials are in deployed state - updateStep(STEP_SYNTH, { status: 'running' }); + // Synthesize CloudFormation now that credentials are in deployed state + updateStepByLabel(LABEL_SYNTH, { status: 'running' }); logger.startStep('Synthesize CloudFormation'); + let synthStackNames: string[]; try { const synthResult = await synthesizeCdk(context.cdkProject, { ioHost: switchableIoHost.ioHost, @@ -669,17 +760,55 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { wrapperRef.current = synthResult.toolkitWrapper; setCdkToolkitWrapper(synthResult.toolkitWrapper); setStackNames(synthResult.stackNames); + synthStackNames = synthResult.stackNames; logger.endStep('success'); - updateStep(STEP_SYNTH, { status: 'success' }); + updateStepByLabel(LABEL_SYNTH, { status: 'success' }); } catch (err) { const errorMsg = formatError(err); logger.endStep('error', errorMsg); - updateStep(STEP_SYNTH, { status: 'error', error: logger.getFailureMessage('Synthesize CloudFormation') }); + updateStepByLabel(LABEL_SYNTH, { + status: 'error', + error: logger.getFailureMessage('Synthesize CloudFormation'), + }); setPhase('error'); isRunningRef.current = false; return; } + // Check stack status + if (target && synthStackNames.length > 0) { + updateStepByLabel(LABEL_STACK_STATUS, { status: 'running' }); + logger.startStep('Check stack status'); + try { + const stackStatus = await checkStackDeployability(target.region, synthStackNames); + if (!stackStatus.canDeploy) { + const errorMsg = stackStatus.message ?? `Stack ${stackStatus.blockingStack} is not in a deployable state`; + logger.endStep('error', errorMsg); + updateStepByLabel(LABEL_STACK_STATUS, { status: 'error', error: errorMsg }); + setPhase('error'); + isRunningRef.current = false; + return; + } + logger.endStep('success'); + updateStepByLabel(LABEL_STACK_STATUS, { status: 'success' }); + } catch (err) { + const errorMsg = formatError(err); + logger.endStep('error', errorMsg); + if (isExpiredTokenError(err)) { + setHasTokenExpiredError(true); + } + updateStepByLabel(LABEL_STACK_STATUS, { + status: 'error', + error: logger.getFailureMessage('Check stack status'), + }); + setPhase('error'); + isRunningRef.current = false; + return; + } + } else { + updateStepByLabel(LABEL_STACK_STATUS, { status: 'success' }); + } + // Check if bootstrap is needed const bootstrapCheck = await checkBootstrapNeeded(context.awsTargets); if (bootstrapCheck.needsBootstrap && bootstrapCheck.target) { @@ -712,7 +841,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { }; void runIdentitySetup(); - }, [phase, context, skipIdentitySetup, runtimeCredentials, logger]); + }, [phase, context, skipIdentitySetup, runtimeCredentials, logger, switchableIoHost.ioHost]); // Handle bootstrapping phase useEffect(() => { diff --git a/src/cli/tui/screens/add/AddFlow.tsx b/src/cli/tui/screens/add/AddFlow.tsx index fbead7de..313b439f 100644 --- a/src/cli/tui/screens/add/AddFlow.tsx +++ b/src/cli/tui/screens/add/AddFlow.tsx @@ -361,10 +361,6 @@ export function AddFlow(props: AddFlowProps) { // Identity wizard - now uses AddIdentityFlow with mode selection if (flow.name === 'identity-wizard') { - // Wait for agents to load before rendering wizard - if (agents.length === 0) { - return null; - } return ( ADD_RESOURCES.map(r => ({ ...r, - disabled: Boolean('disabled' in r && r.disabled) || ((r.id === 'memory' || r.id === 'identity') && !hasAgents), - description: (r.id === 'memory' || r.id === 'identity') && !hasAgents ? 'Add an agent first' : r.description, + disabled: Boolean('disabled' in r && r.disabled) || (r.id === 'memory' && !hasAgents), + description: r.id === 'memory' && !hasAgents ? 'Add an agent first' : r.description, })), [hasAgents] ); diff --git a/src/cli/tui/screens/identity/AddIdentityFlow.tsx b/src/cli/tui/screens/identity/AddIdentityFlow.tsx index 093331dc..5240dfb7 100644 --- a/src/cli/tui/screens/identity/AddIdentityFlow.tsx +++ b/src/cli/tui/screens/identity/AddIdentityFlow.tsx @@ -35,11 +35,26 @@ export function AddIdentityFlow({ isInteractive = true, onExit, onBack, onDev, o const handleCreateComplete = useCallback( (config: AddIdentityConfig) => { - void createIdentity({ - type: 'ApiKeyCredentialProvider', - name: config.name, - apiKey: config.apiKey, - }).then(result => { + const createConfig = + config.identityType === 'OAuthCredentialProvider' + ? { + type: 'OAuthCredentialProvider' as const, + name: config.name, + discoveryUrl: config.discoveryUrl!, + clientId: config.clientId!, + clientSecret: config.clientSecret!, + scopes: config.scopes + ?.split(',') + .map(s => s.trim()) + .filter(Boolean), + } + : { + type: 'ApiKeyCredentialProvider' as const, + name: config.name, + apiKey: config.apiKey, + }; + + void createIdentity(createConfig).then(result => { if (result.ok) { setFlow({ name: 'create-success', identityName: result.result.name }); return; @@ -63,7 +78,7 @@ export function AddIdentityFlow({ isInteractive = true, onExit, onBack, onDev, o void; onExit: () => void; existingIdentityNames: string[]; + initialType?: CredentialType; } -export function AddIdentityScreen({ onComplete, onExit, existingIdentityNames }: AddIdentityScreenProps) { - const wizard = useAddIdentityWizard(); +export function AddIdentityScreen({ onComplete, onExit, existingIdentityNames, initialType }: AddIdentityScreenProps) { + const wizard = useAddIdentityWizard(initialType); const typeItems: SelectableItem[] = useMemo( () => IDENTITY_TYPE_OPTIONS.map(opt => ({ id: opt.id, title: opt.title, description: opt.description })), @@ -27,7 +28,12 @@ export function AddIdentityScreen({ onComplete, onExit, existingIdentityNames }: const isTypeStep = wizard.step === 'type'; const isNameStep = wizard.step === 'name'; const isApiKeyStep = wizard.step === 'apiKey'; + const isDiscoveryUrlStep = wizard.step === 'discoveryUrl'; + const isClientIdStep = wizard.step === 'clientId'; + const isClientSecretStep = wizard.step === 'clientSecret'; + const isScopesStep = wizard.step === 'scopes'; const isConfirmStep = wizard.step === 'confirm'; + const isOAuth = wizard.config.identityType === 'OAuthCredentialProvider'; const typeNav = useListNavigation({ items: typeItems, @@ -51,6 +57,10 @@ export function AddIdentityScreen({ onComplete, onExit, existingIdentityNames }: const headerContent = ; + const defaultName = isOAuth + ? generateUniqueName('MyOAuth', existingIdentityNames) + : generateUniqueName('MyApiKey', existingIdentityNames); + return ( @@ -67,10 +77,11 @@ export function AddIdentityScreen({ onComplete, onExit, existingIdentityNames }: wizard.goBack()} schema={CredentialNameSchema} + customValidation={value => !existingIdentityNames.includes(value) || 'Credential name already exists'} /> )} @@ -85,13 +96,81 @@ export function AddIdentityScreen({ onComplete, onExit, existingIdentityNames }: /> )} + {isDiscoveryUrlStep && ( + wizard.goBack()} + customValidation={value => { + try { + new URL(value); + } catch { + return 'Must be a valid URL'; + } + if (!value.endsWith('/.well-known/openid-configuration')) { + return "URL must end with '/.well-known/openid-configuration'"; + } + return true; + }} + /> + )} + + {isClientIdStep && ( + wizard.goBack()} + customValidation={value => value.trim().length > 0 || 'Client ID is required'} + revealChars={4} + /> + )} + + {isClientSecretStep && ( + wizard.goBack()} + customValidation={value => value.trim().length > 0 || 'Client secret is required'} + revealChars={4} + /> + )} + + {isScopesStep && ( + wizard.goBack()} + allowEmpty + /> + )} + {isConfirmStep && ( )} diff --git a/src/cli/tui/screens/identity/types.ts b/src/cli/tui/screens/identity/types.ts index b936a1e2..49bdf6bf 100644 --- a/src/cli/tui/screens/identity/types.ts +++ b/src/cli/tui/screens/identity/types.ts @@ -4,18 +4,36 @@ import type { CredentialType } from '../../../../schema'; // Identity Flow Types // ───────────────────────────────────────────────────────────────────────────── -export type AddIdentityStep = 'type' | 'name' | 'apiKey' | 'confirm'; +export type AddIdentityStep = + | 'type' + | 'name' + | 'apiKey' + | 'discoveryUrl' + | 'clientId' + | 'clientSecret' + | 'scopes' + | 'confirm'; export interface AddIdentityConfig { identityType: CredentialType; name: string; + /** API Key (when type is ApiKeyCredentialProvider) */ apiKey: string; + /** OAuth fields (when type is OAuthCredentialProvider) */ + discoveryUrl?: string; + clientId?: string; + clientSecret?: string; + scopes?: string; } export const IDENTITY_STEP_LABELS: Record = { type: 'Type', name: 'Name', apiKey: 'API Key', + discoveryUrl: 'Discovery URL', + clientId: 'Client ID', + clientSecret: 'Client Secret', + scopes: 'Scopes', confirm: 'Confirm', }; @@ -25,4 +43,5 @@ export const IDENTITY_STEP_LABELS: Record = { export const IDENTITY_TYPE_OPTIONS = [ { id: 'ApiKeyCredentialProvider' as const, title: 'API Key', description: 'Store and manage API key credentials' }, + { id: 'OAuthCredentialProvider' as const, title: 'OAuth', description: 'OAuth 2.0 client credentials' }, ] as const; diff --git a/src/cli/tui/screens/identity/useAddIdentityWizard.ts b/src/cli/tui/screens/identity/useAddIdentityWizard.ts index b870091c..ea1271f1 100644 --- a/src/cli/tui/screens/identity/useAddIdentityWizard.ts +++ b/src/cli/tui/screens/identity/useAddIdentityWizard.ts @@ -1,74 +1,126 @@ import type { CredentialType } from '../../../../schema'; import type { AddIdentityConfig, AddIdentityStep } from './types'; -import { useCallback, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; -const ALL_STEPS: AddIdentityStep[] = ['type', 'name', 'apiKey', 'confirm']; +function getSteps(identityType: CredentialType, skipTypeStep: boolean): AddIdentityStep[] { + const steps: AddIdentityStep[] = + identityType === 'OAuthCredentialProvider' + ? ['type', 'name', 'discoveryUrl', 'clientId', 'clientSecret', 'scopes', 'confirm'] + : ['type', 'name', 'apiKey', 'confirm']; -function getDefaultConfig(): AddIdentityConfig { + return skipTypeStep ? steps.filter(s => s !== 'type') : steps; +} + +function getDefaultConfig(initialType?: CredentialType): AddIdentityConfig { return { - identityType: 'ApiKeyCredentialProvider', + identityType: initialType ?? 'ApiKeyCredentialProvider', name: '', apiKey: '', }; } -export function useAddIdentityWizard() { - const [config, setConfig] = useState(getDefaultConfig); - const [step, setStep] = useState('type'); +export function useAddIdentityWizard(initialType?: CredentialType) { + const hasInitialType = initialType !== undefined; + const [config, setConfig] = useState(() => getDefaultConfig(initialType)); + const [step, setStep] = useState(hasInitialType ? 'name' : 'type'); - const currentIndex = ALL_STEPS.indexOf(step); + const steps = useMemo(() => getSteps(config.identityType, hasInitialType), [config.identityType, hasInitialType]); + const currentIndex = steps.indexOf(step); const goBack = useCallback(() => { - const prevStep = ALL_STEPS[currentIndex - 1]; + const prevStep = steps[currentIndex - 1]; if (prevStep) setStep(prevStep); - }, [currentIndex]); - - const nextStep = useCallback((currentStep: AddIdentityStep): AddIdentityStep | undefined => { - const idx = ALL_STEPS.indexOf(currentStep); - return ALL_STEPS[idx + 1]; - }, []); + }, [currentIndex, steps]); - const setIdentityType = useCallback( - (identityType: CredentialType) => { - setConfig(c => ({ ...c, identityType })); - const next = nextStep('type'); + const advanceFrom = useCallback( + (currentStep: AddIdentityStep) => { + const currentSteps = getSteps(config.identityType, hasInitialType); + const idx = currentSteps.indexOf(currentStep); + const next = currentSteps[idx + 1]; if (next) setStep(next); }, - [nextStep] + [config.identityType, hasInitialType] ); + const setIdentityType = useCallback((identityType: CredentialType) => { + setConfig(c => ({ + ...c, + identityType, + apiKey: '', + discoveryUrl: undefined, + clientId: undefined, + clientSecret: undefined, + scopes: undefined, + })); + setStep('name'); + }, []); + const setName = useCallback( (name: string) => { setConfig(c => ({ ...c, name })); - const next = nextStep('name'); - if (next) setStep(next); + advanceFrom('name'); }, - [nextStep] + [advanceFrom] ); const setApiKey = useCallback( (apiKey: string) => { setConfig(c => ({ ...c, apiKey })); - const next = nextStep('apiKey'); - if (next) setStep(next); + advanceFrom('apiKey'); + }, + [advanceFrom] + ); + + const setDiscoveryUrl = useCallback( + (discoveryUrl: string) => { + setConfig(c => ({ ...c, discoveryUrl })); + advanceFrom('discoveryUrl'); }, - [nextStep] + [advanceFrom] + ); + + const setClientId = useCallback( + (clientId: string) => { + setConfig(c => ({ ...c, clientId })); + advanceFrom('clientId'); + }, + [advanceFrom] + ); + + const setClientSecret = useCallback( + (clientSecret: string) => { + setConfig(c => ({ ...c, clientSecret })); + advanceFrom('clientSecret'); + }, + [advanceFrom] + ); + + const setScopes = useCallback( + (scopes: string) => { + setConfig(c => ({ ...c, scopes: scopes || undefined })); + advanceFrom('scopes'); + }, + [advanceFrom] ); const reset = useCallback(() => { - setConfig(getDefaultConfig()); - setStep('type'); - }, []); + setConfig(getDefaultConfig(initialType)); + setStep(hasInitialType ? 'name' : 'type'); + }, [initialType, hasInitialType]); return { config, step, - steps: ALL_STEPS, + steps, currentIndex, goBack, setIdentityType, setName, setApiKey, + setDiscoveryUrl, + setClientId, + setClientSecret, + setScopes, reset, }; } diff --git a/src/cli/tui/screens/identity/useCreateIdentity.ts b/src/cli/tui/screens/identity/useCreateIdentity.ts index f53f73db..1dee9e37 100644 --- a/src/cli/tui/screens/identity/useCreateIdentity.ts +++ b/src/cli/tui/screens/identity/useCreateIdentity.ts @@ -3,6 +3,7 @@ import { type CreateCredentialConfig, createCredential, getAllCredentialNames, + getAllCredentials, } from '../../../operations/identity/create-identity'; import { useCallback, useEffect, useState } from 'react'; @@ -50,5 +51,20 @@ export function useExistingCredentialNames() { return { names, refresh }; } +export function useExistingCredentials() { + const [credentials, setCredentials] = useState([]); + + useEffect(() => { + void getAllCredentials().then(setCredentials); + }, []); + + const refresh = useCallback(async () => { + const result = await getAllCredentials(); + setCredentials(result); + }, []); + + return { credentials, refresh }; +} + // Alias for old name export const useExistingIdentityNames = useExistingCredentialNames; diff --git a/src/cli/tui/screens/mcp/AddGatewayTargetFlow.tsx b/src/cli/tui/screens/mcp/AddGatewayTargetFlow.tsx index c6cce11d..a840d68e 100644 --- a/src/cli/tui/screens/mcp/AddGatewayTargetFlow.tsx +++ b/src/cli/tui/screens/mcp/AddGatewayTargetFlow.tsx @@ -2,12 +2,16 @@ import { createExternalGatewayTarget } from '../../../operations/mcp/create-mcp' import { ErrorPrompt } from '../../components'; import { useCreateGatewayTarget, useExistingGateways, useExistingToolNames } from '../../hooks/useCreateMcp'; import { AddSuccessScreen } from '../add/AddSuccessScreen'; +import { AddIdentityScreen } from '../identity/AddIdentityScreen'; +import type { AddIdentityConfig } from '../identity/types'; +import { useCreateIdentity, useExistingCredentials, useExistingIdentityNames } from '../identity/useCreateIdentity'; import { AddGatewayTargetScreen } from './AddGatewayTargetScreen'; import type { AddGatewayTargetConfig } from './types'; -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; type FlowState = | { name: 'create-wizard' } + | { name: 'creating-credential'; pendingConfig: AddGatewayTargetConfig } | { name: 'create-success'; toolName: string; projectPath: string; loading?: boolean; loadingMessage?: string } | { name: 'error'; message: string }; @@ -32,8 +36,16 @@ export function AddGatewayTargetFlow({ const { createTool, reset: resetCreate } = useCreateGatewayTarget(); const { gateways: existingGateways } = useExistingGateways(); const { toolNames: existingToolNames } = useExistingToolNames(); + const { credentials } = useExistingCredentials(); + const { names: existingIdentityNames } = useExistingIdentityNames(); + const { createIdentity } = useCreateIdentity(); const [flow, setFlow] = useState({ name: 'create-wizard' }); + const oauthCredentialNames = useMemo( + () => credentials.filter(c => c.type === 'OAuthCredentialProvider').map(c => c.name), + [credentials] + ); + // In non-interactive mode, exit after success (but not while loading) useEffect(() => { if (!isInteractive && flow.name === 'create-success' && !flow.loading) { @@ -73,18 +85,72 @@ export function AddGatewayTargetFlow({ [createTool] ); + const handleCreateCredential = useCallback((pendingConfig: AddGatewayTargetConfig) => { + setFlow({ name: 'creating-credential', pendingConfig }); + }, []); + + const handleIdentityComplete = useCallback( + (identityConfig: AddIdentityConfig) => { + const createConfig = + identityConfig.identityType === 'OAuthCredentialProvider' + ? { + type: 'OAuthCredentialProvider' as const, + name: identityConfig.name, + discoveryUrl: identityConfig.discoveryUrl!, + clientId: identityConfig.clientId!, + clientSecret: identityConfig.clientSecret!, + scopes: identityConfig.scopes + ?.split(',') + .map(s => s.trim()) + .filter(Boolean), + } + : { + type: 'ApiKeyCredentialProvider' as const, + name: identityConfig.name, + apiKey: identityConfig.apiKey, + }; + + void createIdentity(createConfig).then(result => { + if (result.ok && flow.name === 'creating-credential') { + const finalConfig: AddGatewayTargetConfig = { + ...flow.pendingConfig, + outboundAuth: { type: 'OAUTH', credentialName: result.result.name }, + }; + handleCreateComplete(finalConfig); + } else if (!result.ok) { + setFlow({ name: 'error', message: result.error }); + } + }); + }, + [flow, createIdentity, handleCreateComplete] + ); + // Create wizard if (flow.name === 'create-wizard') { return ( ); } + // Creating credential via identity screen + if (flow.name === 'creating-credential') { + return ( + setFlow({ name: 'create-wizard' })} + initialType="OAuthCredentialProvider" + /> + ); + } + // Create success if (flow.name === 'create-success') { return ( diff --git a/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx b/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx index 1dd507ae..169b9633 100644 --- a/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx +++ b/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx @@ -1,10 +1,9 @@ import { ToolNameSchema } from '../../../../schema'; -import { ConfirmReview, Panel, Screen, SecretInput, StepIndicator, TextInput, WizardSelect } from '../../components'; +import { ConfirmReview, Panel, Screen, StepIndicator, TextInput, WizardSelect } from '../../components'; import type { SelectableItem } from '../../components'; import { HELP_TEXT } from '../../constants'; import { useListNavigation } from '../../hooks'; import { generateUniqueName } from '../../utils'; -import { useCreateIdentity, useExistingCredentialNames } from '../identity/useCreateIdentity.js'; import type { AddGatewayTargetConfig } from './types'; import { MCP_TOOL_STEP_LABELS, OUTBOUND_AUTH_OPTIONS } from './types'; import { useAddGatewayTargetWizard } from './useAddGatewayTargetWizard'; @@ -14,28 +13,23 @@ import React, { useMemo, useState } from 'react'; interface AddGatewayTargetScreenProps { existingGateways: string[]; existingToolNames: string[]; + existingOAuthCredentialNames: string[]; onComplete: (config: AddGatewayTargetConfig) => void; + onCreateCredential: (pendingConfig: AddGatewayTargetConfig) => void; onExit: () => void; } export function AddGatewayTargetScreen({ existingGateways, existingToolNames, + existingOAuthCredentialNames, onComplete, + onCreateCredential, onExit, }: AddGatewayTargetScreenProps) { const wizard = useAddGatewayTargetWizard(existingGateways); - const { names: existingCredentialNames } = useExistingCredentialNames(); - const { createIdentity } = useCreateIdentity(); - // Outbound auth sub-step state const [outboundAuthType, setOutboundAuthTypeLocal] = useState(null); - const [credentialName, setCredentialNameLocal] = useState(null); - const [isCreatingCredential, setIsCreatingCredential] = useState(false); - const [oauthSubStep, setOauthSubStep] = useState<'name' | 'client-id' | 'client-secret' | 'discovery-url'>('name'); - const [oauthFields, setOauthFields] = useState({ name: '', clientId: '', clientSecret: '', discoveryUrl: '' }); - const [apiKeySubStep, setApiKeySubStep] = useState<'name' | 'api-key'>('name'); - const [apiKeyFields, setApiKeyFields] = useState({ name: '', apiKey: '' }); const gatewayItems: SelectableItem[] = useMemo( () => existingGateways.map(g => ({ id: g, title: g })), @@ -48,14 +42,14 @@ export function AddGatewayTargetScreen({ ); const credentialItems: SelectableItem[] = useMemo(() => { - const items: SelectableItem[] = [ - { id: 'create-new', title: 'Create new credential', description: 'Create a new credential inline' }, - ]; - existingCredentialNames.forEach(name => { - items.push({ id: name, title: name, description: 'Use existing credential' }); - }); + const items: SelectableItem[] = existingOAuthCredentialNames.map(name => ({ + id: name, + title: name, + description: 'Use existing OAuth credential', + })); + items.push({ id: 'create-new', title: 'Create new credential', description: 'Create a new OAuth credential' }); return items; - }, [existingCredentialNames]); + }, [existingOAuthCredentialNames]); const isGatewayStep = wizard.step === 'gateway'; const isOutboundAuthStep = wizard.step === 'outbound-auth'; @@ -73,10 +67,14 @@ export function AddGatewayTargetScreen({ const outboundAuthNav = useListNavigation({ items: outboundAuthItems, onSelect: item => { - const authType = item.id as 'OAUTH' | 'API_KEY' | 'NONE'; - setOutboundAuthTypeLocal(authType); + const authType = item.id as 'OAUTH' | 'NONE'; if (authType === 'NONE') { wizard.setOutboundAuth({ type: 'NONE' }); + } else if (existingOAuthCredentialNames.length === 0) { + // No existing OAuth credentials — go straight to creation + onCreateCredential(wizard.config); + } else { + setOutboundAuthTypeLocal(authType); } }, onExit: () => wizard.goBack(), @@ -87,28 +85,15 @@ export function AddGatewayTargetScreen({ items: credentialItems, onSelect: item => { if (item.id === 'create-new') { - setIsCreatingCredential(true); - if (outboundAuthType === 'OAUTH') { - setOauthSubStep('name'); - } else { - setApiKeySubStep('name'); - } + onCreateCredential(wizard.config); } else { - setCredentialNameLocal(item.id); - wizard.setOutboundAuth({ type: outboundAuthType as 'OAUTH' | 'API_KEY', credentialName: item.id }); + wizard.setOutboundAuth({ type: 'OAUTH', credentialName: item.id }); } }, onExit: () => { setOutboundAuthTypeLocal(null); - setCredentialNameLocal(null); - setIsCreatingCredential(false); }, - isActive: - isOutboundAuthStep && - !!outboundAuthType && - outboundAuthType !== 'NONE' && - !credentialName && - !isCreatingCredential, + isActive: isOutboundAuthStep && outboundAuthType === 'OAUTH', }); useListNavigation({ @@ -116,121 +101,14 @@ export function AddGatewayTargetScreen({ onSelect: () => onComplete(wizard.config), onExit: () => { setOutboundAuthTypeLocal(null); - setCredentialNameLocal(null); - setIsCreatingCredential(false); - setOauthSubStep('name'); - setOauthFields({ name: '', clientId: '', clientSecret: '', discoveryUrl: '' }); - setApiKeySubStep('name'); - setApiKeyFields({ name: '', apiKey: '' }); wizard.goBack(); }, isActive: isConfirmStep, }); - // OAuth creation handlers - const handleOauthFieldSubmit = (value: string) => { - const newFields = { ...oauthFields }; - - if (oauthSubStep === 'name') { - newFields.name = value; - setOauthFields(newFields); - setOauthSubStep('client-id'); - } else if (oauthSubStep === 'client-id') { - newFields.clientId = value; - setOauthFields(newFields); - setOauthSubStep('client-secret'); - } else if (oauthSubStep === 'client-secret') { - newFields.clientSecret = value; - setOauthFields(newFields); - setOauthSubStep('discovery-url'); - } else if (oauthSubStep === 'discovery-url') { - newFields.discoveryUrl = value; - setOauthFields(newFields); - - // Create the credential - void createIdentity({ - type: 'OAuthCredentialProvider', - name: newFields.name, - clientId: newFields.clientId, - clientSecret: newFields.clientSecret, - discoveryUrl: newFields.discoveryUrl, - }) - .then(result => { - if (result.ok) { - wizard.setOutboundAuth({ type: 'OAUTH', credentialName: newFields.name }); - } else { - setIsCreatingCredential(false); - setOauthSubStep('name'); - setOauthFields({ name: '', clientId: '', clientSecret: '', discoveryUrl: '' }); - } - }) - .catch(() => { - setIsCreatingCredential(false); - setOauthSubStep('name'); - setOauthFields({ name: '', clientId: '', clientSecret: '', discoveryUrl: '' }); - }); - } - }; - - const handleOauthFieldCancel = () => { - if (oauthSubStep === 'name') { - setIsCreatingCredential(false); - setOauthFields({ name: '', clientId: '', clientSecret: '', discoveryUrl: '' }); - } else if (oauthSubStep === 'client-id') { - setOauthSubStep('name'); - } else if (oauthSubStep === 'client-secret') { - setOauthSubStep('client-id'); - } else if (oauthSubStep === 'discovery-url') { - setOauthSubStep('client-secret'); - } - }; - - // API Key creation handlers - const handleApiKeyFieldSubmit = (value: string) => { - const newFields = { ...apiKeyFields }; - - if (apiKeySubStep === 'name') { - newFields.name = value; - setApiKeyFields(newFields); - setApiKeySubStep('api-key'); - } else if (apiKeySubStep === 'api-key') { - newFields.apiKey = value; - setApiKeyFields(newFields); - - void createIdentity({ - type: 'ApiKeyCredentialProvider', - name: newFields.name, - apiKey: newFields.apiKey, - }) - .then(result => { - if (result.ok) { - wizard.setOutboundAuth({ type: 'API_KEY', credentialName: newFields.name }); - } else { - setIsCreatingCredential(false); - setApiKeySubStep('name'); - setApiKeyFields({ name: '', apiKey: '' }); - } - }) - .catch(() => { - setIsCreatingCredential(false); - setApiKeySubStep('name'); - setApiKeyFields({ name: '', apiKey: '' }); - }); - } - }; - - const handleApiKeyFieldCancel = () => { - if (apiKeySubStep === 'name') { - setIsCreatingCredential(false); - setApiKeyFields({ name: '', apiKey: '' }); - } else if (apiKeySubStep === 'api-key') { - setApiKeySubStep('name'); - } - }; - const helpText = isConfirmStep ? HELP_TEXT.CONFIRM_CANCEL - : isTextStep || isCreatingCredential + : isTextStep ? HELP_TEXT.TEXT_INPUT : HELP_TEXT.NAVIGATE_SELECT; @@ -259,96 +137,13 @@ export function AddGatewayTargetScreen({ /> )} - {isOutboundAuthStep && - outboundAuthType && - outboundAuthType !== 'NONE' && - !credentialName && - !isCreatingCredential && ( - - )} - - {isOutboundAuthStep && isCreatingCredential && outboundAuthType === 'OAUTH' && ( - <> - {oauthSubStep === 'name' && ( - !existingCredentialNames.includes(value) || 'Credential name already exists'} - /> - )} - {oauthSubStep === 'client-id' && ( - value.trim().length > 0 || 'Client ID is required'} - /> - )} - {oauthSubStep === 'client-secret' && ( - value.trim().length > 0 || 'Client secret is required'} - revealChars={4} - /> - )} - {oauthSubStep === 'discovery-url' && ( - { - try { - const url = new URL(value); - if (url.protocol !== 'http:' && url.protocol !== 'https:') { - return 'Discovery URL must use http:// or https:// protocol'; - } - return true; - } catch { - return 'Must be a valid URL'; - } - }} - /> - )} - - )} - - {isOutboundAuthStep && isCreatingCredential && outboundAuthType === 'API_KEY' && ( - <> - {apiKeySubStep === 'name' && ( - !existingCredentialNames.includes(value) || 'Credential name already exists'} - /> - )} - {apiKeySubStep === 'api-key' && ( - value.trim().length > 0 || 'API key is required'} - revealChars={4} - /> - )} - + {isOutboundAuthStep && outboundAuthType === 'OAUTH' && ( + )} {isTextStep && ( diff --git a/src/cli/tui/screens/remove/RemoveScreen.tsx b/src/cli/tui/screens/remove/RemoveScreen.tsx index f64ddc8b..bcb7307c 100644 --- a/src/cli/tui/screens/remove/RemoveScreen.tsx +++ b/src/cli/tui/screens/remove/RemoveScreen.tsx @@ -6,7 +6,7 @@ const REMOVE_RESOURCES = [ { id: 'agent', title: 'Agent', description: 'Remove an agent from the project' }, { id: 'memory', title: 'Memory', description: 'Remove a memory provider' }, { id: 'identity', title: 'Identity', description: 'Remove an identity provider' }, - { id: 'gateway', title: 'Gateway', description: 'Remove an MCP gateway' }, + { id: 'gateway', title: 'Gateway', description: 'Remove a gateway' }, { id: 'gateway-target', title: 'Gateway Target', description: 'Remove a gateway target' }, { id: 'all', title: 'All', description: 'Reset entire agentcore project' }, ] as const; diff --git a/src/cli/tui/screens/schema/EditSchemaScreen.tsx b/src/cli/tui/screens/schema/EditSchemaScreen.tsx index 42846e09..1721f5f8 100644 --- a/src/cli/tui/screens/schema/EditSchemaScreen.tsx +++ b/src/cli/tui/screens/schema/EditSchemaScreen.tsx @@ -44,7 +44,7 @@ export function EditSchemaScreen(props: EditSchemaScreenProps) { { id: 'mcp', title: 'mcp.json', - description: `MCP gateways and tools${mcpMissing}`, + description: `Gateways and tools${mcpMissing}`, filePath: mcpPath, schema: AgentCoreMcpSpecSchema, }, diff --git a/src/cli/tui/screens/schema/McpGuidedEditor.tsx b/src/cli/tui/screens/schema/McpGuidedEditor.tsx index 30760bd0..28534403 100644 --- a/src/cli/tui/screens/schema/McpGuidedEditor.tsx +++ b/src/cli/tui/screens/schema/McpGuidedEditor.tsx @@ -631,7 +631,7 @@ function McpEditorBody(props: { - + {gateways.length === 0 ? ( No gateways configured. Press A to add one. ) : ( From f295ffdf5589b408d098c6a019d297b901c78ef3 Mon Sep 17 00:00:00 2001 From: Aidan Daly <99039782+aidandaly24@users.noreply.github.com> Date: Mon, 2 Mar 2026 14:47:41 -0500 Subject: [PATCH 26/28] feat: implement inbound auth (#467) * feat: extend JWT wizard with allowedScopes and agent OAuth credential inputs * feat: auto-create managed OAuth credential for CUSTOM_JWT gateway * feat: add CLI flags for CUSTOM_JWT agent OAuth credentials * feat: add CUSTOM_JWT Bearer token auth to agent templates (Strands, LangChain, OpenAI, Google ADK) * feat: protect managed credentials from accidental deletion * test: add tests for CUSTOM_JWT CLI validation and managed credential protection * fix: resolve httpx import collision between AWS_IAM and CUSTOM_JWT templates * fix: use placeholder instead of initialValue for gateway discovery URL * feat: wire CUSTOM_JWT inbound auth through AgentCore identity system --- .../assets.snapshot.test.ts.snap | 83 +++++++++++++++++ .../googleadk/base/mcp_client/client.py | 21 +++++ .../base/mcp_client/client.py | 21 +++++ .../openaiagents/base/mcp_client/client.py | 21 +++++ .../python/strands/base/mcp_client/client.py | 20 +++++ .../commands/add/__tests__/validate.test.ts | 41 +++++++++ src/cli/commands/add/actions.ts | 11 +++ src/cli/commands/add/command.tsx | 6 ++ src/cli/commands/add/types.ts | 3 + src/cli/commands/add/validate.ts | 11 +++ src/cli/commands/remove/actions.ts | 2 +- .../agent/generate/schema-mapper.ts | 31 +++++-- .../operations/identity/create-identity.ts | 2 + src/cli/operations/mcp/create-mcp.ts | 16 ++++ .../remove/__tests__/remove-identity.test.ts | 59 +++++++++++- src/cli/operations/remove/remove-identity.ts | 18 +++- src/cli/templates/types.ts | 6 ++ src/cli/tui/hooks/useRemove.ts | 2 +- src/cli/tui/screens/mcp/AddGatewayScreen.tsx | 90 +++++++++++++++++-- src/cli/tui/screens/mcp/types.ts | 3 + .../tui/screens/mcp/useAddGatewayWizard.ts | 9 +- src/schema/schemas/agentcore-project.ts | 2 + 22 files changed, 459 insertions(+), 19 deletions(-) diff --git a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap index 02841a5b..0e2f5950 100644 --- a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap +++ b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap @@ -1749,6 +1749,23 @@ logger = logging.getLogger(__name__) import httpx from mcp_proxy_for_aws.sigv4_helper import SigV4HTTPXAuth, create_aws_session {{/if}} +{{#if (includes gatewayAuthTypes "CUSTOM_JWT")}} +from bedrock_agentcore.identity import requires_access_token +{{/if}} + +{{#each gatewayProviders}} +{{#if (eq authType "CUSTOM_JWT")}} +@requires_access_token( + provider_name="{{credentialProviderName}}", + scopes=[{{#if scopes}}"{{scopes}}"{{/if}}], + auth_flow="M2M", +) +def _get_bearer_token_{{snakeCase name}}(*, access_token: str): + """Obtain OAuth access token via AgentCore Identity for {{name}}.""" + return access_token + +{{/if}} +{{/each}} def get_all_gateway_mcp_toolsets() -> list[MCPToolset]: """Returns MCP Toolsets for all configured gateways.""" @@ -1763,6 +1780,10 @@ def get_all_gateway_mcp_toolsets() -> list[MCPToolset]: url=url, httpx_client_factory=lambda **kwargs: httpx.AsyncClient(auth=auth, **kwargs) ))) + {{else if (eq authType "CUSTOM_JWT")}} + token = _get_bearer_token_{{snakeCase name}}() + headers = {"Authorization": f"Bearer {token}"} if token else None + toolsets.append(MCPToolset(connection_params=StreamableHTTPConnectionParams(url=url, headers=headers))) {{else}} toolsets.append(MCPToolset(connection_params=StreamableHTTPConnectionParams(url=url))) {{/if}} @@ -2035,6 +2056,23 @@ logger = logging.getLogger(__name__) {{#if (includes gatewayAuthTypes "AWS_IAM")}} from mcp_proxy_for_aws.sigv4_helper import SigV4HTTPXAuth, create_aws_session {{/if}} +{{#if (includes gatewayAuthTypes "CUSTOM_JWT")}} +from bedrock_agentcore.identity import requires_access_token +{{/if}} + +{{#each gatewayProviders}} +{{#if (eq authType "CUSTOM_JWT")}} +@requires_access_token( + provider_name="{{credentialProviderName}}", + scopes=[{{#if scopes}}"{{scopes}}"{{/if}}], + auth_flow="M2M", +) +def _get_bearer_token_{{snakeCase name}}(*, access_token: str): + """Obtain OAuth access token via AgentCore Identity for {{name}}.""" + return access_token + +{{/if}} +{{/each}} def get_all_gateway_mcp_client() -> MultiServerMCPClient | None: """Returns an MCP Client connected to all configured gateways.""" @@ -2046,6 +2084,10 @@ def get_all_gateway_mcp_client() -> MultiServerMCPClient | None: session = create_aws_session() auth = SigV4HTTPXAuth(session.get_credentials(), "bedrock-agentcore", session.region_name) servers["{{name}}"] = {"transport": "streamable_http", "url": url, "auth": auth} + {{else if (eq authType "CUSTOM_JWT")}} + token = _get_bearer_token_{{snakeCase name}}() + headers = {"Authorization": f"Bearer {token}"} if token else None + servers["{{name}}"] = {"transport": "streamable_http", "url": url, "headers": headers} {{else}} servers["{{name}}"] = {"transport": "streamable_http", "url": url} {{/if}} @@ -2460,6 +2502,23 @@ logger = logging.getLogger(__name__) import httpx from mcp_proxy_for_aws.sigv4_helper import SigV4HTTPXAuth, create_aws_session {{/if}} +{{#if (includes gatewayAuthTypes "CUSTOM_JWT")}} +from bedrock_agentcore.identity import requires_access_token +{{/if}} + +{{#each gatewayProviders}} +{{#if (eq authType "CUSTOM_JWT")}} +@requires_access_token( + provider_name="{{credentialProviderName}}", + scopes=[{{#if scopes}}"{{scopes}}"{{/if}}], + auth_flow="M2M", +) +def _get_bearer_token_{{snakeCase name}}(*, access_token: str): + """Obtain OAuth access token via AgentCore Identity for {{name}}.""" + return access_token + +{{/if}} +{{/each}} def get_all_gateway_mcp_servers() -> list[MCPServerStreamableHttp]: """Returns MCP servers for all configured gateways.""" @@ -2474,6 +2533,10 @@ def get_all_gateway_mcp_servers() -> list[MCPServerStreamableHttp]: name="{{name}}", params={"url": url, "httpx_client_factory": lambda **kwargs: httpx.AsyncClient(auth=auth, **kwargs)} )) + {{else if (eq authType "CUSTOM_JWT")}} + token = _get_bearer_token_{{snakeCase name}}() + headers = {"Authorization": f"Bearer {token}"} if token else {} + servers.append(MCPServerStreamableHttp(name="{{name}}", params={"url": url, "headers": headers})) {{else}} servers.append(MCPServerStreamableHttp(name="{{name}}", params={"url": url})) {{/if}} @@ -2771,7 +2834,23 @@ logger = logging.getLogger(__name__) {{#if (includes gatewayAuthTypes "AWS_IAM")}} from mcp_proxy_for_aws.client import aws_iam_streamablehttp_client {{/if}} +{{#if (includes gatewayAuthTypes "CUSTOM_JWT")}} +from bedrock_agentcore.identity import requires_access_token +{{/if}} + +{{#each gatewayProviders}} +{{#if (eq authType "CUSTOM_JWT")}} +@requires_access_token( + provider_name="{{credentialProviderName}}", + scopes=[{{#if scopes}}"{{scopes}}"{{/if}}], + auth_flow="M2M", +) +def _get_bearer_token_{{snakeCase name}}(*, access_token: str): + """Obtain OAuth access token via AgentCore Identity for {{name}}.""" + return access_token +{{/if}} +{{/each}} {{#each gatewayProviders}} def get_{{snakeCase name}}_mcp_client() -> MCPClient | None: """Returns an MCP Client connected to the {{name}} gateway.""" @@ -2781,6 +2860,10 @@ def get_{{snakeCase name}}_mcp_client() -> MCPClient | None: return None {{#if (eq authType "AWS_IAM")}} return MCPClient(lambda: aws_iam_streamablehttp_client(url, aws_service="bedrock-agentcore", aws_region=os.environ.get("AWS_REGION", os.environ.get("AWS_DEFAULT_REGION")))) + {{else if (eq authType "CUSTOM_JWT")}} + token = _get_bearer_token_{{snakeCase name}}() + headers = {"Authorization": f"Bearer {token}"} if token else {} + return MCPClient(lambda: streamablehttp_client(url, headers=headers)) {{else}} return MCPClient(lambda: streamablehttp_client(url)) {{/if}} diff --git a/src/assets/python/googleadk/base/mcp_client/client.py b/src/assets/python/googleadk/base/mcp_client/client.py index f2c1a39c..e6dddd62 100644 --- a/src/assets/python/googleadk/base/mcp_client/client.py +++ b/src/assets/python/googleadk/base/mcp_client/client.py @@ -10,6 +10,23 @@ import httpx from mcp_proxy_for_aws.sigv4_helper import SigV4HTTPXAuth, create_aws_session {{/if}} +{{#if (includes gatewayAuthTypes "CUSTOM_JWT")}} +from bedrock_agentcore.identity import requires_access_token +{{/if}} + +{{#each gatewayProviders}} +{{#if (eq authType "CUSTOM_JWT")}} +@requires_access_token( + provider_name="{{credentialProviderName}}", + scopes=[{{#if scopes}}"{{scopes}}"{{/if}}], + auth_flow="M2M", +) +def _get_bearer_token_{{snakeCase name}}(*, access_token: str): + """Obtain OAuth access token via AgentCore Identity for {{name}}.""" + return access_token + +{{/if}} +{{/each}} def get_all_gateway_mcp_toolsets() -> list[MCPToolset]: """Returns MCP Toolsets for all configured gateways.""" @@ -24,6 +41,10 @@ def get_all_gateway_mcp_toolsets() -> list[MCPToolset]: url=url, httpx_client_factory=lambda **kwargs: httpx.AsyncClient(auth=auth, **kwargs) ))) + {{else if (eq authType "CUSTOM_JWT")}} + token = _get_bearer_token_{{snakeCase name}}() + headers = {"Authorization": f"Bearer {token}"} if token else None + toolsets.append(MCPToolset(connection_params=StreamableHTTPConnectionParams(url=url, headers=headers))) {{else}} toolsets.append(MCPToolset(connection_params=StreamableHTTPConnectionParams(url=url))) {{/if}} diff --git a/src/assets/python/langchain_langgraph/base/mcp_client/client.py b/src/assets/python/langchain_langgraph/base/mcp_client/client.py index adcb478a..71b336d2 100644 --- a/src/assets/python/langchain_langgraph/base/mcp_client/client.py +++ b/src/assets/python/langchain_langgraph/base/mcp_client/client.py @@ -8,6 +8,23 @@ {{#if (includes gatewayAuthTypes "AWS_IAM")}} from mcp_proxy_for_aws.sigv4_helper import SigV4HTTPXAuth, create_aws_session {{/if}} +{{#if (includes gatewayAuthTypes "CUSTOM_JWT")}} +from bedrock_agentcore.identity import requires_access_token +{{/if}} + +{{#each gatewayProviders}} +{{#if (eq authType "CUSTOM_JWT")}} +@requires_access_token( + provider_name="{{credentialProviderName}}", + scopes=[{{#if scopes}}"{{scopes}}"{{/if}}], + auth_flow="M2M", +) +def _get_bearer_token_{{snakeCase name}}(*, access_token: str): + """Obtain OAuth access token via AgentCore Identity for {{name}}.""" + return access_token + +{{/if}} +{{/each}} def get_all_gateway_mcp_client() -> MultiServerMCPClient | None: """Returns an MCP Client connected to all configured gateways.""" @@ -19,6 +36,10 @@ def get_all_gateway_mcp_client() -> MultiServerMCPClient | None: session = create_aws_session() auth = SigV4HTTPXAuth(session.get_credentials(), "bedrock-agentcore", session.region_name) servers["{{name}}"] = {"transport": "streamable_http", "url": url, "auth": auth} + {{else if (eq authType "CUSTOM_JWT")}} + token = _get_bearer_token_{{snakeCase name}}() + headers = {"Authorization": f"Bearer {token}"} if token else None + servers["{{name}}"] = {"transport": "streamable_http", "url": url, "headers": headers} {{else}} servers["{{name}}"] = {"transport": "streamable_http", "url": url} {{/if}} diff --git a/src/assets/python/openaiagents/base/mcp_client/client.py b/src/assets/python/openaiagents/base/mcp_client/client.py index 39612c38..2fe91136 100644 --- a/src/assets/python/openaiagents/base/mcp_client/client.py +++ b/src/assets/python/openaiagents/base/mcp_client/client.py @@ -9,6 +9,23 @@ import httpx from mcp_proxy_for_aws.sigv4_helper import SigV4HTTPXAuth, create_aws_session {{/if}} +{{#if (includes gatewayAuthTypes "CUSTOM_JWT")}} +from bedrock_agentcore.identity import requires_access_token +{{/if}} + +{{#each gatewayProviders}} +{{#if (eq authType "CUSTOM_JWT")}} +@requires_access_token( + provider_name="{{credentialProviderName}}", + scopes=[{{#if scopes}}"{{scopes}}"{{/if}}], + auth_flow="M2M", +) +def _get_bearer_token_{{snakeCase name}}(*, access_token: str): + """Obtain OAuth access token via AgentCore Identity for {{name}}.""" + return access_token + +{{/if}} +{{/each}} def get_all_gateway_mcp_servers() -> list[MCPServerStreamableHttp]: """Returns MCP servers for all configured gateways.""" @@ -23,6 +40,10 @@ def get_all_gateway_mcp_servers() -> list[MCPServerStreamableHttp]: name="{{name}}", params={"url": url, "httpx_client_factory": lambda **kwargs: httpx.AsyncClient(auth=auth, **kwargs)} )) + {{else if (eq authType "CUSTOM_JWT")}} + token = _get_bearer_token_{{snakeCase name}}() + headers = {"Authorization": f"Bearer {token}"} if token else {} + servers.append(MCPServerStreamableHttp(name="{{name}}", params={"url": url, "headers": headers})) {{else}} servers.append(MCPServerStreamableHttp(name="{{name}}", params={"url": url})) {{/if}} diff --git a/src/assets/python/strands/base/mcp_client/client.py b/src/assets/python/strands/base/mcp_client/client.py index 3b77cdac..01457de2 100644 --- a/src/assets/python/strands/base/mcp_client/client.py +++ b/src/assets/python/strands/base/mcp_client/client.py @@ -9,7 +9,23 @@ {{#if (includes gatewayAuthTypes "AWS_IAM")}} from mcp_proxy_for_aws.client import aws_iam_streamablehttp_client {{/if}} +{{#if (includes gatewayAuthTypes "CUSTOM_JWT")}} +from bedrock_agentcore.identity import requires_access_token +{{/if}} + +{{#each gatewayProviders}} +{{#if (eq authType "CUSTOM_JWT")}} +@requires_access_token( + provider_name="{{credentialProviderName}}", + scopes=[{{#if scopes}}"{{scopes}}"{{/if}}], + auth_flow="M2M", +) +def _get_bearer_token_{{snakeCase name}}(*, access_token: str): + """Obtain OAuth access token via AgentCore Identity for {{name}}.""" + return access_token +{{/if}} +{{/each}} {{#each gatewayProviders}} def get_{{snakeCase name}}_mcp_client() -> MCPClient | None: """Returns an MCP Client connected to the {{name}} gateway.""" @@ -19,6 +35,10 @@ def get_{{snakeCase name}}_mcp_client() -> MCPClient | None: return None {{#if (eq authType "AWS_IAM")}} return MCPClient(lambda: aws_iam_streamablehttp_client(url, aws_service="bedrock-agentcore", aws_region=os.environ.get("AWS_REGION", os.environ.get("AWS_DEFAULT_REGION")))) + {{else if (eq authType "CUSTOM_JWT")}} + token = _get_bearer_token_{{snakeCase name}}() + headers = {"Authorization": f"Bearer {token}"} if token else {} + return MCPClient(lambda: streamablehttp_client(url, headers=headers)) {{else}} return MCPClient(lambda: streamablehttp_client(url)) {{/if}} diff --git a/src/cli/commands/add/__tests__/validate.test.ts b/src/cli/commands/add/__tests__/validate.test.ts index 3b319c9e..0d4f7961 100644 --- a/src/cli/commands/add/__tests__/validate.test.ts +++ b/src/cli/commands/add/__tests__/validate.test.ts @@ -240,6 +240,47 @@ describe('validate', () => { expect(validateAddGatewayOptions(validGatewayOptionsNone)).toEqual({ valid: true }); expect(validateAddGatewayOptions(validGatewayOptionsJwt)).toEqual({ valid: true }); }); + + // AC15: agentClientId and agentClientSecret must be provided together + it('returns error when agentClientId provided without agentClientSecret', () => { + const result = validateAddGatewayOptions({ + ...validGatewayOptionsJwt, + agentClientId: 'my-client-id', + }); + expect(result.valid).toBe(false); + expect(result.error).toBe('Both --agent-client-id and --agent-client-secret must be provided together'); + }); + + it('returns error when agentClientSecret provided without agentClientId', () => { + const result = validateAddGatewayOptions({ + ...validGatewayOptionsJwt, + agentClientSecret: 'my-secret', + }); + expect(result.valid).toBe(false); + expect(result.error).toBe('Both --agent-client-id and --agent-client-secret must be provided together'); + }); + + // AC16: agent credentials only valid with CUSTOM_JWT + it('returns error when agent credentials used with non-CUSTOM_JWT authorizer', () => { + const result = validateAddGatewayOptions({ + ...validGatewayOptionsNone, + agentClientId: 'my-client-id', + agentClientSecret: 'my-secret', + }); + expect(result.valid).toBe(false); + expect(result.error).toBe('Agent OAuth credentials are only valid with CUSTOM_JWT authorizer'); + }); + + // AC17: valid CUSTOM_JWT with agent credentials passes + it('passes for CUSTOM_JWT with agent credentials', () => { + const result = validateAddGatewayOptions({ + ...validGatewayOptionsJwt, + agentClientId: 'my-client-id', + agentClientSecret: 'my-secret', + allowedScopes: 'scope1,scope2', + }); + expect(result.valid).toBe(true); + }); }); describe('validateAddGatewayTargetOptions', () => { diff --git a/src/cli/commands/add/actions.ts b/src/cli/commands/add/actions.ts index 675c52e8..7232f7c7 100644 --- a/src/cli/commands/add/actions.ts +++ b/src/cli/commands/add/actions.ts @@ -64,6 +64,9 @@ export interface ValidatedAddGatewayOptions { discoveryUrl?: string; allowedAudience?: string; allowedClients?: string; + allowedScopes?: string; + agentClientId?: string; + agentClientSecret?: string; agents?: string; } @@ -267,6 +270,14 @@ function buildGatewayConfig(options: ValidatedAddGatewayOptions): AddGatewayConf .allowedClients!.split(',') .map(s => s.trim()) .filter(Boolean), + allowedScopes: options.allowedScopes + ? options.allowedScopes + .split(',') + .map(s => s.trim()) + .filter(Boolean) + : undefined, + agentClientId: options.agentClientId, + agentClientSecret: options.agentClientSecret, }; } diff --git a/src/cli/commands/add/command.tsx b/src/cli/commands/add/command.tsx index 6a9370bb..22e89dc5 100644 --- a/src/cli/commands/add/command.tsx +++ b/src/cli/commands/add/command.tsx @@ -82,6 +82,9 @@ async function handleAddGatewayCLI(options: AddGatewayOptions): Promise { discoveryUrl: options.discoveryUrl, allowedAudience: options.allowedAudience, allowedClients: options.allowedClients, + allowedScopes: options.allowedScopes, + agentClientId: options.agentClientId, + agentClientSecret: options.agentClientSecret, agents: options.agents, }); @@ -272,6 +275,9 @@ export function registerAdd(program: Command) { .option('--discovery-url ', 'OIDC discovery URL (required for CUSTOM_JWT)') .option('--allowed-audience ', 'Comma-separated allowed audience values (required for CUSTOM_JWT)') .option('--allowed-clients ', 'Comma-separated allowed client IDs (required for CUSTOM_JWT)') + .option('--allowed-scopes ', 'Comma-separated allowed scopes (optional for CUSTOM_JWT)') + .option('--agent-client-id ', 'Agent OAuth client ID for Bearer token auth (CUSTOM_JWT)') + .option('--agent-client-secret ', 'Agent OAuth client secret (CUSTOM_JWT)') .option('--json', 'Output as JSON') .action(async options => { requireProject(); diff --git a/src/cli/commands/add/types.ts b/src/cli/commands/add/types.ts index c83db76d..46757121 100644 --- a/src/cli/commands/add/types.ts +++ b/src/cli/commands/add/types.ts @@ -31,6 +31,9 @@ export interface AddGatewayOptions { discoveryUrl?: string; allowedAudience?: string; allowedClients?: string; + allowedScopes?: string; + agentClientId?: string; + agentClientSecret?: string; agents?: string; json?: boolean; } diff --git a/src/cli/commands/add/validate.ts b/src/cli/commands/add/validate.ts index 9a4bc4df..0aac0a21 100644 --- a/src/cli/commands/add/validate.ts +++ b/src/cli/commands/add/validate.ts @@ -181,6 +181,17 @@ export function validateAddGatewayOptions(options: AddGatewayOptions): Validatio } } + // Validate agent OAuth credentials + if (options.agentClientId && !options.agentClientSecret) { + return { valid: false, error: 'Both --agent-client-id and --agent-client-secret must be provided together' }; + } + if (options.agentClientSecret && !options.agentClientId) { + return { valid: false, error: 'Both --agent-client-id and --agent-client-secret must be provided together' }; + } + if (options.agentClientId && options.authorizerType !== 'CUSTOM_JWT') { + return { valid: false, error: 'Agent OAuth credentials are only valid with CUSTOM_JWT authorizer' }; + } + return { valid: true }; } diff --git a/src/cli/commands/remove/actions.ts b/src/cli/commands/remove/actions.ts index 35681c69..74604ea2 100644 --- a/src/cli/commands/remove/actions.ts +++ b/src/cli/commands/remove/actions.ts @@ -72,7 +72,7 @@ export async function handleRemove(options: ValidatedRemoveOptions): Promise ({ - name: gateway.name, - envVarName: computeDefaultGatewayEnvVarName(gateway.name), - authType: gateway.authorizerType, - })); + const project = await configIO.readProjectSpec(); + + return mcpSpec.agentCoreGateways.map(gateway => { + const config: GatewayProviderRenderConfig = { + name: gateway.name, + envVarName: computeDefaultGatewayEnvVarName(gateway.name), + authType: gateway.authorizerType, + }; + + if (gateway.authorizerType === 'CUSTOM_JWT' && gateway.authorizerConfiguration?.customJwtAuthorizer) { + const jwtConfig = gateway.authorizerConfiguration.customJwtAuthorizer; + const credName = `${gateway.name}-agent-oauth`; + const credential = project.credentials.find(c => c.name === credName); + + if (credential) { + config.credentialProviderName = credName; + config.discoveryUrl = jwtConfig.discoveryUrl; + const scopes = 'allowedScopes' in jwtConfig ? (jwtConfig as { allowedScopes?: string[] }).allowedScopes : undefined; + if (scopes?.length) { + config.scopes = scopes.join(' '); + } + } + } + + return config; + }); } catch { return []; } diff --git a/src/cli/operations/identity/create-identity.ts b/src/cli/operations/identity/create-identity.ts index f42bee61..26a0c672 100644 --- a/src/cli/operations/identity/create-identity.ts +++ b/src/cli/operations/identity/create-identity.ts @@ -14,6 +14,7 @@ export type CreateCredentialConfig = clientSecret: string; scopes?: string[]; vendor?: string; + managed?: boolean; }; /** @@ -143,6 +144,7 @@ export async function createCredential(config: CreateCredentialConfig): Promise< discoveryUrl: config.discoveryUrl, vendor: config.vendor ?? 'CustomOauth2', ...(config.scopes && config.scopes.length > 0 ? { scopes: config.scopes } : {}), + ...(config.managed ? { managed: true } : {}), }; project.credentials.push(credential); await configIO.writeProjectSpec(project); diff --git a/src/cli/operations/mcp/create-mcp.ts b/src/cli/operations/mcp/create-mcp.ts index 1f554642..f8bb6e63 100644 --- a/src/cli/operations/mcp/create-mcp.ts +++ b/src/cli/operations/mcp/create-mcp.ts @@ -11,6 +11,7 @@ import { AgentCoreCliMcpDefsSchema, ToolDefinitionSchema } from '../../../schema import { getTemplateToolDefinitions, renderGatewayTargetTemplate } from '../../templates/GatewayTargetRenderer'; import type { AddGatewayConfig, AddGatewayTargetConfig } from '../../tui/screens/mcp/types'; import { DEFAULT_HANDLER, DEFAULT_NODE_VERSION, DEFAULT_PYTHON_VERSION } from '../../tui/screens/mcp/types'; +import { createCredential } from '../identity/create-identity'; import { existsSync } from 'fs'; import { mkdir, readFile, writeFile } from 'fs/promises'; import { dirname, join } from 'path'; @@ -71,6 +72,7 @@ function buildAuthorizerConfiguration(config: AddGatewayConfig): AgentCoreGatewa discoveryUrl: config.jwtConfig.discoveryUrl, allowedAudience: config.jwtConfig.allowedAudience, allowedClients: config.jwtConfig.allowedClients, + ...(config.jwtConfig.allowedScopes?.length && { allowedScopes: config.jwtConfig.allowedScopes }), }, }; } @@ -201,6 +203,20 @@ export async function createGatewayFromWizard(config: AddGatewayConfig): Promise await configIO.writeMcpSpec(mcpSpec); + // Auto-create managed credential if agent OAuth credentials provided + if (config.jwtConfig?.agentClientId && config.jwtConfig?.agentClientSecret) { + const credName = `${config.name}-agent-oauth`; + await createCredential({ + type: 'OAuthCredentialProvider', + name: credName, + discoveryUrl: config.jwtConfig.discoveryUrl, + clientId: config.jwtConfig.agentClientId, + clientSecret: config.jwtConfig.agentClientSecret, + vendor: 'CustomOauth2', + managed: true, + }); + } + return { name: config.name }; } diff --git a/src/cli/operations/remove/__tests__/remove-identity.test.ts b/src/cli/operations/remove/__tests__/remove-identity.test.ts index b6172a33..2426b345 100644 --- a/src/cli/operations/remove/__tests__/remove-identity.test.ts +++ b/src/cli/operations/remove/__tests__/remove-identity.test.ts @@ -1,8 +1,9 @@ -import { previewRemoveCredential } from '../remove-identity.js'; +import { previewRemoveCredential, removeCredential } from '../remove-identity.js'; import { describe, expect, it, vi } from 'vitest'; -const { mockReadProjectSpec, mockConfigExists, mockReadMcpSpec } = vi.hoisted(() => ({ +const { mockReadProjectSpec, mockWriteProjectSpec, mockConfigExists, mockReadMcpSpec } = vi.hoisted(() => ({ mockReadProjectSpec: vi.fn(), + mockWriteProjectSpec: vi.fn(), mockConfigExists: vi.fn(), mockReadMcpSpec: vi.fn(), })); @@ -10,6 +11,7 @@ const { mockReadProjectSpec, mockConfigExists, mockReadMcpSpec } = vi.hoisted(() vi.mock('../../../../lib/index.js', () => ({ ConfigIO: class { readProjectSpec = mockReadProjectSpec; + writeProjectSpec = mockWriteProjectSpec; configExists = mockConfigExists; readMcpSpec = mockReadMcpSpec; }, @@ -118,4 +120,57 @@ describe('previewRemoveCredential', () => { 'Warning: Credential "test-cred" is referenced by gateway targets: gateway2/target2. Removing it may break these targets.' ); }); + + it('shows managed credential warning in preview', async () => { + mockReadProjectSpec.mockResolvedValue({ + credentials: [{ name: 'gw-agent-oauth', type: 'OAuthCredentialProvider', managed: true, usage: 'inbound' }], + }); + mockConfigExists.mockReturnValue(false); + + const result = await previewRemoveCredential('gw-agent-oauth'); + + const warning = result.summary.find(s => s.includes('auto-created')); + expect(warning).toBeTruthy(); + }); +}); + +describe('removeCredential', () => { + it('blocks removal of managed credential without force', async () => { + mockReadProjectSpec.mockResolvedValue({ + credentials: [{ name: 'gw-agent-oauth', type: 'OAuthCredentialProvider', managed: true, usage: 'inbound' }], + }); + mockConfigExists.mockReturnValue(false); + + const result = await removeCredential('gw-agent-oauth'); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain('auto-created'); + expect(result.error).toContain('--force'); + } + }); + + it('allows removal of managed credential with force', async () => { + mockReadProjectSpec.mockResolvedValue({ + credentials: [{ name: 'gw-agent-oauth', type: 'OAuthCredentialProvider', managed: true, usage: 'inbound' }], + }); + mockConfigExists.mockReturnValue(false); + mockWriteProjectSpec.mockResolvedValue(undefined); + + const result = await removeCredential('gw-agent-oauth', { force: true }); + + expect(result.ok).toBe(true); + }); + + it('allows removal of non-managed credential without force', async () => { + mockReadProjectSpec.mockResolvedValue({ + credentials: [{ name: 'regular-cred', type: 'OAuthCredentialProvider' }], + }); + mockConfigExists.mockReturnValue(false); + mockWriteProjectSpec.mockResolvedValue(undefined); + + const result = await removeCredential('regular-cred'); + + expect(result.ok).toBe(true); + }); }); diff --git a/src/cli/operations/remove/remove-identity.ts b/src/cli/operations/remove/remove-identity.ts index 68c9e417..6c560c64 100644 --- a/src/cli/operations/remove/remove-identity.ts +++ b/src/cli/operations/remove/remove-identity.ts @@ -43,6 +43,12 @@ export async function previewRemoveCredential(credentialName: string): Promise { +export async function removeCredential(credentialName: string, options?: { force?: boolean }): Promise { try { const configIO = new ConfigIO(); const project = await configIO.readProjectSpec(); @@ -95,6 +101,16 @@ export async function removeCredential(credentialName: string): Promise => { setState({ isLoading: true, result: null }); - const result = await removeIdentity(identityName); + const result = await removeIdentity(identityName, { force: true }); setState({ isLoading: false, result }); let logPath: string | undefined; diff --git a/src/cli/tui/screens/mcp/AddGatewayScreen.tsx b/src/cli/tui/screens/mcp/AddGatewayScreen.tsx index 13269eef..dca25086 100644 --- a/src/cli/tui/screens/mcp/AddGatewayScreen.tsx +++ b/src/cli/tui/screens/mcp/AddGatewayScreen.tsx @@ -4,6 +4,7 @@ import { ConfirmReview, Panel, Screen, + SecretInput, StepIndicator, TextInput, WizardMultiSelect, @@ -29,10 +30,13 @@ interface AddGatewayScreenProps { export function AddGatewayScreen({ onComplete, onExit, existingGateways, unassignedTargets }: AddGatewayScreenProps) { const wizard = useAddGatewayWizard(unassignedTargets.length); - // JWT config sub-step tracking (0 = discoveryUrl, 1 = audience, 2 = clients) + // JWT config sub-step tracking (0=discoveryUrl, 1=audience, 2=clients, 3=scopes, 4=agentClientId, 5=agentClientSecret) const [jwtSubStep, setJwtSubStep] = useState(0); const [jwtDiscoveryUrl, setJwtDiscoveryUrl] = useState(''); const [jwtAudience, setJwtAudience] = useState(''); + const [jwtClients, setJwtClients] = useState(''); + const [jwtScopes, setJwtScopes] = useState(''); + const [jwtAgentClientId, setJwtAgentClientId] = useState(''); const unassignedTargetItems: SelectableItem[] = useMemo( () => unassignedTargets.map(name => ({ id: name, title: name })), @@ -85,12 +89,30 @@ export function AddGatewayScreen({ onComplete, onExit, existingGateways, unassig }; const handleJwtClients = (clients: string) => { - // Parse comma-separated values + setJwtClients(clients); + setJwtSubStep(3); + }; + + const handleJwtScopes = (scopes: string) => { + setJwtScopes(scopes); + setJwtSubStep(4); + }; + + const handleJwtAgentClientId = (clientId: string) => { + setJwtAgentClientId(clientId); + setJwtSubStep(5); + }; + + const handleJwtAgentClientSecret = (clientSecret: string) => { const audienceList = jwtAudience .split(',') .map(s => s.trim()) .filter(Boolean); - const clientsList = clients + const clientsList = jwtClients + .split(',') + .map(s => s.trim()) + .filter(Boolean); + const scopesList = jwtScopes .split(',') .map(s => s.trim()) .filter(Boolean); @@ -99,9 +121,10 @@ export function AddGatewayScreen({ onComplete, onExit, existingGateways, unassig discoveryUrl: jwtDiscoveryUrl, allowedAudience: audienceList, allowedClients: clientsList, + ...(scopesList.length > 0 ? { allowedScopes: scopesList } : {}), + ...(jwtAgentClientId ? { agentClientId: jwtAgentClientId, agentClientSecret: clientSecret } : {}), }); - // Reset sub-step counter only - preserve values for potential back navigation setJwtSubStep(0); }; @@ -160,6 +183,9 @@ export function AddGatewayScreen({ onComplete, onExit, existingGateways, unassig onDiscoveryUrl={handleJwtDiscoveryUrl} onAudience={handleJwtAudience} onClients={handleJwtClients} + onScopes={handleJwtScopes} + onAgentClientId={handleJwtAgentClientId} + onAgentClientSecret={handleJwtAgentClientSecret} onCancel={handleJwtCancel} /> )} @@ -187,6 +213,12 @@ export function AddGatewayScreen({ onComplete, onExit, existingGateways, unassig { label: 'Discovery URL', value: wizard.config.jwtConfig.discoveryUrl }, { label: 'Allowed Audience', value: wizard.config.jwtConfig.allowedAudience.join(', ') }, { label: 'Allowed Clients', value: wizard.config.jwtConfig.allowedClients.join(', ') }, + ...(wizard.config.jwtConfig.allowedScopes?.length + ? [{ label: 'Allowed Scopes', value: wizard.config.jwtConfig.allowedScopes.join(', ') }] + : []), + ...(wizard.config.jwtConfig.agentClientId + ? [{ label: 'Agent Credential', value: `${wizard.config.name}-agent-oauth` }] + : []), ] : []), { @@ -209,6 +241,9 @@ interface JwtConfigInputProps { onDiscoveryUrl: (url: string) => void; onAudience: (audience: string) => void; onClients: (clients: string) => void; + onScopes: (scopes: string) => void; + onAgentClientId: (clientId: string) => void; + onAgentClientSecret: (clientSecret: string) => void; onCancel: () => void; } @@ -227,16 +262,28 @@ function validateCommaSeparatedList(value: string, fieldName: string): true | st return true; } -function JwtConfigInput({ subStep, onDiscoveryUrl, onAudience, onClients, onCancel }: JwtConfigInputProps) { +function JwtConfigInput({ + subStep, + onDiscoveryUrl, + onAudience, + onClients, + onScopes, + onAgentClientId, + onAgentClientSecret, + onCancel, +}: JwtConfigInputProps) { + const totalSteps = 6; return ( Configure Custom JWT Authorizer - Step {subStep + 1} of 3 + + Step {subStep + 1} of {totalSteps} + {subStep === 0 && ( { @@ -271,6 +318,33 @@ function JwtConfigInput({ subStep, onDiscoveryUrl, onAudience, onClients, onCanc customValidation={value => validateCommaSeparatedList(value, 'client')} /> )} + {subStep === 3 && ( + + )} + {subStep === 4 && ( + + )} + {subStep === 5 && ( + value.trim().length > 0 || 'Client secret is required'} + revealChars={4} + /> + )} ); diff --git a/src/cli/tui/screens/mcp/types.ts b/src/cli/tui/screens/mcp/types.ts index fcf7d593..f24aeed5 100644 --- a/src/cli/tui/screens/mcp/types.ts +++ b/src/cli/tui/screens/mcp/types.ts @@ -16,6 +16,9 @@ export interface AddGatewayConfig { discoveryUrl: string; allowedAudience: string[]; allowedClients: string[]; + allowedScopes?: string[]; + agentClientId?: string; + agentClientSecret?: string; }; /** Selected unassigned targets to include in this gateway */ selectedTargets?: string[]; diff --git a/src/cli/tui/screens/mcp/useAddGatewayWizard.ts b/src/cli/tui/screens/mcp/useAddGatewayWizard.ts index 2bd24b75..90265bca 100644 --- a/src/cli/tui/screens/mcp/useAddGatewayWizard.ts +++ b/src/cli/tui/screens/mcp/useAddGatewayWizard.ts @@ -68,7 +68,14 @@ export function useAddGatewayWizard(unassignedTargetsCount = 0) { }, []); const setJwtConfig = useCallback( - (jwtConfig: { discoveryUrl: string; allowedAudience: string[]; allowedClients: string[] }) => { + (jwtConfig: { + discoveryUrl: string; + allowedAudience: string[]; + allowedClients: string[]; + allowedScopes?: string[]; + agentClientId?: string; + agentClientSecret?: string; + }) => { setConfig(c => ({ ...c, jwtConfig, diff --git a/src/schema/schemas/agentcore-project.ts b/src/schema/schemas/agentcore-project.ts index 13f8241f..fda34160 100644 --- a/src/schema/schemas/agentcore-project.ts +++ b/src/schema/schemas/agentcore-project.ts @@ -101,6 +101,8 @@ export const OAuthCredentialSchema = z.object({ vendor: z.string().default('CustomOauth2'), /** Whether this credential was auto-created by the CLI (e.g., for CUSTOM_JWT inbound auth) */ managed: z.boolean().optional(), + /** Whether this credential is used for inbound or outbound auth */ + usage: z.enum(['inbound', 'outbound']).optional(), }); export type OAuthCredential = z.infer; From 5105654fdd2be00cd1a1dc5ba0078bc7ace99ae3 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 2 Mar 2026 15:17:58 -0500 Subject: [PATCH 27/28] fix: prettier formatting and remove redundant condition --- src/cli/operations/agent/generate/schema-mapper.ts | 3 ++- src/cli/tui/screens/schema/McpGuidedEditor.tsx | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/cli/operations/agent/generate/schema-mapper.ts b/src/cli/operations/agent/generate/schema-mapper.ts index b90212c6..f3bc3617 100644 --- a/src/cli/operations/agent/generate/schema-mapper.ts +++ b/src/cli/operations/agent/generate/schema-mapper.ts @@ -205,7 +205,8 @@ async function mapMcpGatewaysToGatewayProviders(): Promise 0 && !assigningTarget) { + if (unassignedTargets.length > 0) { // U key to focus unassigned targets if (input.toLowerCase() === 'u') { setSelectedUnassignedIndex(0); From 81379a3c391afe8e0857f8f557070cefe7b82f8e Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 2 Mar 2026 15:30:26 -0500 Subject: [PATCH 28/28] fix: sanitize OAuth error to prevent clear-text logging of sensitive data --- src/cli/commands/deploy/actions.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/cli/commands/deploy/actions.ts b/src/cli/commands/deploy/actions.ts index 2b4dbd94..6dacc3ef 100644 --- a/src/cli/commands/deploy/actions.ts +++ b/src/cli/commands/deploy/actions.ts @@ -168,8 +168,10 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise r.status === 'error'); - const errorMsg = errorResult?.error ?? 'OAuth credential setup failed'; + logger.log(`OAuth setup error: ${errorResult?.error ?? 'unknown'}`, 'error'); + const errorMsg = 'OAuth credential setup failed. Check the log for details.'; endStep('error', errorMsg); logger.finalize(false); return { success: false, error: errorMsg, logPath: logger.getRelativeLogPath() };