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 8de5fc00..0e2f5950 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,22 @@ async function main() { const spec = await configIO.readProjectSpec(); const targets = await configIO.readAWSDeploymentTargets(); + // Read MCP configuration if it exists + let mcpSpec; + try { + mcpSpec = await configIO.readMcpSpec(); + } 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'); } @@ -82,8 +99,19 @@ 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, + credentials, env, description: \`AgentCore stack for \${spec.name} deployed to \${target.name} (\${target.region})\`, tags: { @@ -222,7 +250,12 @@ 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 AgentCoreMcpSpec, +} from '@aws/agentcore-cdk'; import { CfnOutput, Stack, type StackProps } from 'aws-cdk-lib'; import { Construct } from 'constructs'; @@ -231,6 +264,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?: AgentCoreMcpSpec; + /** + * Credential provider ARNs from deployed state, keyed by credential name. + */ + credentials?: Record; } /** @@ -246,13 +287,23 @@ export class AgentCoreStack extends Stack { constructor(scope: Construct, id: string, props: AgentCoreStackProps) { super(scope, id, props); - const { spec } = props; + const { spec, mcpSpec, credentials } = 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', { + projectName: spec.name, + mcpSpec, + agentCoreApplication: this.application, + credentials, + }); + } + // Stack-level output new CfnOutput(this, 'StackNameOutput', { description: 'Name of the CloudFormation Stack', @@ -300,7 +351,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" } } @@ -1584,7 +1635,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 @@ -1602,7 +1657,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 @@ -1677,21 +1737,72 @@ 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}} +{{#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.""" + 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 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}} + 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}} " `; @@ -1762,6 +1873,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] @@ -1864,7 +1977,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 @@ -1894,10 +2011,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) @@ -1923,16 +2046,64 @@ 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}} +{{#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.""" + 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 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}} + 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( { @@ -1942,6 +2113,7 @@ def get_streamable_http_mcp_client() -> MultiServerMCPClient: } } ) +{{/if}} " `; @@ -2109,6 +2281,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] @@ -2209,13 +2383,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 @@ -2237,16 +2420,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 @@ -2277,20 +2491,71 @@ 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}} +{{#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.""" + 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 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}} + 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}} " `; @@ -2356,6 +2621,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] @@ -2454,7 +2721,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}} @@ -2463,7 +2734,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 = [] @@ -2475,6 +2750,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(): @@ -2489,7 +2769,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 @@ -2505,7 +2785,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}} @@ -2543,18 +2823,71 @@ 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}} +{{#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.""" + 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 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}} + +{{/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`] = ` @@ -2709,6 +3042,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/cdk/bin/cdk.ts b/src/assets/cdk/bin/cdk.ts index 9b2ead1c..9b23d57d 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,22 @@ async function main() { const spec = await configIO.readProjectSpec(); const targets = await configIO.readAWSDeploymentTargets(); + // Read MCP configuration if it exists + let mcpSpec; + try { + mcpSpec = await configIO.readMcpSpec(); + } 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'); } @@ -37,8 +54,19 @@ 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, + 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 051ad235..ecbf15b8 100644 --- a/src/assets/cdk/lib/cdk-stack.ts +++ b/src/assets/cdk/lib/cdk-stack.ts @@ -1,4 +1,9 @@ -import { AgentCoreApplication, type AgentCoreProjectSpec } from '@aws/agentcore-cdk'; +import { + AgentCoreApplication, + AgentCoreMcp, + type AgentCoreProjectSpec, + type AgentCoreMcpSpec, +} from '@aws/agentcore-cdk'; import { CfnOutput, Stack, type StackProps } from 'aws-cdk-lib'; import { Construct } from 'constructs'; @@ -7,6 +12,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?: AgentCoreMcpSpec; + /** + * Credential provider ARNs from deployed state, keyed by credential name. + */ + credentials?: Record; } /** @@ -22,13 +35,23 @@ export class AgentCoreStack extends Stack { constructor(scope: Construct, id: string, props: AgentCoreStackProps) { super(scope, id, props); - const { spec } = props; + const { spec, mcpSpec, credentials } = 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', { + projectName: spec.name, + mcpSpec, + agentCoreApplication: this.application, + credentials, + }); + } + // Stack-level output new CfnOutput(this, 'StackNameOutput', { description: 'Name of the CloudFormation Stack', 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/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..e6dddd62 100644 --- a/src/assets/python/googleadk/base/mcp_client/client.py +++ b/src/assets/python/googleadk/base/mcp_client/client.py @@ -1,15 +1,66 @@ +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}} +{{#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.""" + 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 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}} + 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..71b336d2 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,61 @@ +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}} +{{#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.""" + 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 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}} + 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 +65,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..2fe91136 100644 --- a/src/assets/python/openaiagents/base/mcp_client/client.py +++ b/src/assets/python/openaiagents/base/mcp_client/client.py @@ -1,14 +1,65 @@ +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}} +{{#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.""" + 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 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}} + 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..01457de2 100644 --- a/src/assets/python/strands/base/mcp_client/client.py +++ b/src/assets/python/strands/base/mcp_client/client.py @@ -1,12 +1,64 @@ +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}} +{{#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.""" + 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 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}} + +{{/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/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..39745b4a 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', () => { @@ -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' ); @@ -59,4 +61,125 @@ 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 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', + }; + + const gatewaySpecs = { + 'my-gateway': {}, + 'another-gateway': {}, + }; + + const result = parseGatewayOutputs(outputs, gatewaySpecs); + + expect(result).toEqual({ + 'my-gateway': { + gatewayId: 'gw-123', + gatewayArn: 'arn:aws:bedrock:us-east-1:123:gateway/gw-123', + gatewayUrl: 'https://api.gateway.url', + }, + 'another-gateway': { + gatewayId: 'gw-456', + gatewayArn: 'arn:aws:bedrock:us-east-1:123:gateway/gw-456', + gatewayUrl: '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 = { + GatewayFirstGatewayArnOutput123: 'arn:first', + GatewayFirstGatewayUrlOutput123: 'https://first.url', + GatewaySecondGatewayArnOutput456: 'arn:second', + GatewaySecondGatewayUrlOutput456: 'https://second.url', + GatewayThirdGatewayArnOutput789: 'arn:third', + 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']?.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 6b8624b5..8f4ba433 100644 --- a/src/cli/cloudformation/outputs.ts +++ b/src/cli/cloudformation/outputs.ts @@ -26,6 +26,53 @@ 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 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); + if (!match) continue; + + const logicalGateway = match[1]; + 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: '' }; + + 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; +} + /** * Parse stack outputs into deployed state for agents. * @@ -132,8 +179,10 @@ export function buildDeployedState( targetName: string, stackName: string, agents: Record, + gateways: Record, existingState?: DeployedState, - identityKmsKeyArn?: string + identityKmsKeyArn?: string, + credentials?: Record ): DeployedState { const targetState: TargetDeployedState = { resources: { @@ -143,6 +192,18 @@ export function buildDeployedState( }, }; + // Add MCP state if gateways exist + if (Object.keys(gateways).length > 0) { + targetState.resources!.mcp = { + gateways, + }; + } + + // 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/add/__tests__/actions.test.ts b/src/cli/commands/add/__tests__/actions.test.ts new file mode 100644 index 00000000..0fffde89 --- /dev/null +++ b/src/cli/commands/add/__tests__/actions.test.ts @@ -0,0 +1,100 @@ +import { buildGatewayTargetConfig } from '../actions.js'; +import type { ValidatedAddGatewayTargetOptions } from '../actions.js'; +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', () => { + 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(); + }); +}); + +// 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__/add-gateway-target.test.ts b/src/cli/commands/add/__tests__/add-gateway-target.test.ts new file mode 100644 index 00000000..bbec3694 --- /dev/null +++ b/src/cli/commands/add/__tests__/add-gateway-target.test.ts @@ -0,0 +1,150 @@ +import { runCLI } from '../../../../test-utils/index.js'; +import { randomUUID } from 'node:crypto'; +import { mkdir, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +// Gateway Target feature is disabled (coming soon) - skip all tests +describe.skip('add gateway-target command', () => { + let testDir: string; + let projectDir: string; + const gatewayName = 'test-gateway'; + + beforeAll(async () => { + testDir = join(tmpdir(), `agentcore-add-gateway-target-${randomUUID()}`); + await mkdir(testDir, { recursive: true }); + + // Create project + const projectName = 'GatewayTargetProj'; + 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); + }); + + afterAll(async () => { + await rm(testDir, { recursive: true, force: true }); + }); + + describe('validation', () => { + it('requires name flag', async () => { + 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); + expect(json.error.includes('--name'), `Error: ${json.error}`).toBeTruthy(); + }); + + it('validates language', async () => { + const result = await runCLI( + ['add', 'gateway-target', '--name', 'test', '--language', 'InvalidLang', '--json'], + projectDir + ); + expect(result.exitCode).toBe(1); + const json = JSON.parse(result.stdout); + expect(json.success).toBe(false); + expect( + json.error.toLowerCase().includes('invalid') || json.error.toLowerCase().includes('valid options'), + `Error should mention invalid language: ${json.error}` + ).toBeTruthy(); + }); + + it('accepts Other as valid language option', async () => { + const result = await runCLI( + ['add', 'gateway-target', '--name', 'container-tool', '--language', 'Other', '--json'], + projectDir + ); + + // Should fail with "not yet supported" error, not validation error + expect(result.exitCode).toBe(1); + const json = JSON.parse(result.stdout); + expect(json.success).toBe(false); + expect( + json.error.toLowerCase().includes('not yet supported') || json.error.toLowerCase().includes('other'), + `Error should mention Other not supported: ${json.error}` + ).toBeTruthy(); + }); + }); + + // Gateway disabled - skip behind-gateway tests until gateway feature is enabled + describe.skip('behind-gateway', () => { + it('creates behind-gateway tool', async () => { + const toolName = `gwtool${Date.now()}`; + const result = await runCLI( + [ + 'add', + 'gateway-target', + '--name', + toolName, + '--language', + 'Python', + '--gateway', + gatewayName, + '--host', + 'Lambda', + '--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 gateway targets + const mcpSpec = JSON.parse(await readFile(join(projectDir, 'agentcore/mcp.json'), 'utf-8')); + const gateway = mcpSpec.agentCoreGateways.find((g: { name: string }) => g.name === gatewayName); + const target = gateway?.targets?.find((t: { name: string }) => t.name === toolName); + expect(target, 'Tool should be in gateway targets').toBeTruthy(); + }); + + it('requires gateway for behind-gateway', async () => { + const result = await runCLI( + ['add', 'gateway-target', '--name', 'no-gw', '--language', 'Python', '--host', 'Lambda', '--json'], + projectDir + ); + expect(result.exitCode).toBe(1); + const json = JSON.parse(result.stdout); + expect(json.success).toBe(false); + expect(json.error.includes('--gateway'), `Error: ${json.error}`).toBeTruthy(); + }); + + it('requires host for behind-gateway', async () => { + const result = await runCLI( + ['add', 'gateway-target', '--name', 'no-host', '--language', 'Python', '--gateway', gatewayName, '--json'], + projectDir + ); + expect(result.exitCode).toBe(1); + const json = JSON.parse(result.stdout); + expect(json.success).toBe(false); + expect(json.error.includes('--host'), `Error: ${json.error}`).toBeTruthy(); + }); + + it('returns clear error for Other language with behind-gateway', async () => { + const result = await runCLI( + [ + 'add', + 'gateway-target', + '--name', + 'gateway-container', + '--language', + 'Other', + '--gateway', + gatewayName, + '--host', + 'Lambda', + '--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(); + }); + }); +}); diff --git a/src/cli/commands/add/__tests__/add-mcp-tool.test.ts b/src/cli/commands/add/__tests__/add-mcp-tool.test.ts deleted file mode 100644 index 22c7b42b..00000000 --- a/src/cli/commands/add/__tests__/add-mcp-tool.test.ts +++ /dev/null @@ -1,307 +0,0 @@ -import { runCLI } from '../../../../test-utils/index.js'; -import { randomUUID } from 'node:crypto'; -import { mkdir, readFile, rm } from 'node:fs/promises'; -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 -describe.skip('add mcp-tool 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()}`); - await mkdir(testDir, { recursive: true }); - - // Create project with agent - const projectName = 'McpToolProj'; - 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}`); - } - 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 () => { - await rm(testDir, { recursive: true, force: true }); - }); - - describe('validation', () => { - it('requires name flag', async () => { - const result = await runCLI(['add', 'mcp-tool', '--json'], projectDir); - expect(result.exitCode).toBe(1); - const json = JSON.parse(result.stdout); - expect(json.success).toBe(false); - expect(json.error.includes('--name'), `Error: ${json.error}`).toBeTruthy(); - }); - - it('requires exposure flag', async () => { - const result = await runCLI(['add', 'mcp-tool', '--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', - 'mcp-tool', - '--name', - 'test', - '--language', - 'InvalidLang', - '--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.toLowerCase().includes('invalid') || json.error.toLowerCase().includes('valid options'), - `Error should mention invalid language: ${json.error}` - ).toBeTruthy(); - }); - - it('accepts Other as valid language option', async () => { - const result = await runCLI( - [ - 'add', - 'mcp-tool', - '--name', - 'container-tool', - '--language', - 'Other', - '--exposure', - 'mcp-runtime', - '--agents', - agentName, - '--json', - ], - projectDir - ); - - // Should fail with "not yet supported" error, not validation error - expect(result.exitCode).toBe(1); - const json = JSON.parse(result.stdout); - expect(json.success).toBe(false); - expect( - json.error.toLowerCase().includes('not yet supported') || json.error.toLowerCase().includes('other'), - `Error should mention Other not supported: ${json.error}` - ).toBeTruthy(); - }); - }); - - describe('mcp-runtime', () => { - it('creates mcp-runtime tool', async () => { - const toolName = `rttool${Date.now()}`; - const result = await runCLI( - [ - 'add', - 'mcp-tool', - '--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', 'mcp-tool', '--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', - 'mcp-tool', - '--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 () => { - const toolName = `gwtool${Date.now()}`; - const result = await runCLI( - [ - 'add', - 'mcp-tool', - '--name', - toolName, - '--language', - 'Python', - '--exposure', - 'behind-gateway', - '--gateway', - gatewayName, - '--host', - 'Lambda', - '--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 gateway targets - const mcpSpec = JSON.parse(await readFile(join(projectDir, 'agentcore/mcp.json'), 'utf-8')); - const gateway = mcpSpec.agentCoreGateways.find((g: { name: string }) => g.name === gatewayName); - const target = gateway?.targets?.find((t: { name: string }) => t.name === toolName); - expect(target, 'Tool should be in gateway targets').toBeTruthy(); - }); - - it('requires gateway for behind-gateway', async () => { - const result = await runCLI( - [ - 'add', - 'mcp-tool', - '--name', - 'no-gw', - '--language', - 'Python', - '--exposure', - 'behind-gateway', - '--host', - 'Lambda', - '--json', - ], - projectDir - ); - expect(result.exitCode).toBe(1); - const json = JSON.parse(result.stdout); - expect(json.success).toBe(false); - expect(json.error.includes('--gateway'), `Error: ${json.error}`).toBeTruthy(); - }); - - it('requires host for behind-gateway', async () => { - const result = await runCLI( - [ - 'add', - 'mcp-tool', - '--name', - 'no-host', - '--language', - 'Python', - '--exposure', - 'behind-gateway', - '--gateway', - gatewayName, - '--json', - ], - projectDir - ); - expect(result.exitCode).toBe(1); - const json = JSON.parse(result.stdout); - expect(json.success).toBe(false); - expect(json.error.includes('--host'), `Error: ${json.error}`).toBeTruthy(); - }); - - it('returns clear error for Other language with behind-gateway', async () => { - const result = await runCLI( - [ - 'add', - 'mcp-tool', - '--name', - 'gateway-container', - '--language', - 'Other', - '--exposure', - 'behind-gateway', - '--gateway', - gatewayName, - '--host', - 'Lambda', - '--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(); - }); - }); -}); diff --git a/src/cli/commands/add/__tests__/validate.test.ts b/src/cli/commands/add/__tests__/validate.test.ts index cefc1a73..0d4f7961 100644 --- a/src/cli/commands/add/__tests__/validate.test.ts +++ b/src/cli/commands/add/__tests__/validate.test.ts @@ -1,18 +1,31 @@ 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'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockReadProjectSpec = vi.fn(); +const mockGetExistingGateways = vi.fn(); + +vi.mock('../../../../lib/index.js', () => ({ + ConfigIO: class { + readProjectSpec = mockReadProjectSpec; + }, +})); + +vi.mock('../../../operations/mcp/create-mcp.js', () => ({ + getExistingGateways: (...args: unknown[]) => mockGetExistingGateways(...args), +})); // Helper: valid base options for each type const validAgentOptionsByo: AddAgentOptions = { @@ -46,17 +59,9 @@ const validGatewayOptionsJwt: AddGatewayOptions = { allowedClients: 'client1,client2', }; -const validMcpToolOptionsMcpRuntime: AddMcpToolOptions = { - name: 'test-tool', - language: 'Python', - exposure: 'mcp-runtime', - agents: 'Agent1,Agent2', -}; - -const validMcpToolOptionsBehindGateway: AddMcpToolOptions = { +const validGatewayTargetOptions: AddGatewayTargetOptions = { name: 'test-tool', language: 'Python', - exposure: 'behind-gateway', gateway: 'my-gateway', host: 'Lambda', }; @@ -72,6 +77,8 @@ const validIdentityOptions: AddIdentityOptions = { }; describe('validate', () => { + afterEach(() => vi.clearAllMocks()); + describe('validateAddAgentOptions', () => { // AC1: All required fields validated it('returns error for missing required fields', () => { @@ -233,57 +240,283 @@ 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('validateAddMcpToolOptions', () => { + 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', () => { - const requiredFields: { field: keyof AddMcpToolOptions; error: string }[] = [ - { field: 'name', error: '--name is required' }, - { field: 'language', error: '--language is required' }, - { field: 'exposure', error: '--exposure 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 = { ...validMcpToolOptionsMcpRuntime, [field]: undefined }; - const result = validateAddMcpToolOptions(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 - it('returns error for invalid values', () => { - let result = validateAddMcpToolOptions({ ...validMcpToolOptionsMcpRuntime, language: 'Java' as any }); + it('returns error for invalid values', async () => { + const result = await validateAddGatewayTargetOptions({ + ...validGatewayTargetOptions, + language: 'Java' as any, + }); expect(result.valid).toBe(false); expect(result.error?.includes('Invalid language')).toBeTruthy(); + }); - result = validateAddMcpToolOptions({ ...validMcpToolOptionsMcpRuntime, exposure: 'invalid' as any }); + // AC18: Valid options pass + it('passes for valid gateway target options', async () => { + 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', + gateway: 'my-gateway', + }; + 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', + gateway: 'my-gateway', + }; + 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', + gateway: 'my-gateway', + }; + 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', + gateway: 'my-gateway', + }; + 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', + gateway: 'my-gateway', + }; + const result = await validateAddGatewayTargetOptions(options); expect(result.valid).toBe(false); - expect(result.error?.includes('Invalid exposure')).toBeTruthy(); + expect(result.error).toBe('Endpoint must be a valid URL (e.g. https://example.com/mcp)'); }); - // AC17: mcp-runtime exposure requires agents - it('returns error for mcp-runtime without agents', () => { - let result = validateAddMcpToolOptions({ ...validMcpToolOptionsMcpRuntime, agents: undefined }); + // 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', + gateway: 'my-gateway', + 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', + gateway: 'my-gateway', + outboundAuthType: 'API_KEY', + credentialName: 'any-cred', + }; + const result = await validateAddGatewayTargetOptions(options); expect(result.valid).toBe(false); - expect(result.error).toBe('--agents is required for mcp-runtime exposure'); + 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', + gateway: 'my-gateway', + outboundAuthType: 'API_KEY', + credentialName: 'valid-cred', + }; + 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); + }); - result = validateAddMcpToolOptions({ ...validMcpToolOptionsMcpRuntime, agents: ',,,' }); + 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).toBe('At least one agent is required'); + expect(result.error).toContain('--credential-name or inline OAuth fields'); }); - // AC18: behind-gateway exposure is disabled (coming soon) - it('returns coming soon error for behind-gateway exposure', () => { - const result = validateAddMcpToolOptions({ ...validMcpToolOptionsBehindGateway }); + 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('coming soon'); + expect(result.error).toContain('--oauth-client-secret'); }); - // AC19: Valid options pass - it('passes for valid mcp-runtime options', () => { - expect(validateAddMcpToolOptions(validMcpToolOptionsMcpRuntime)).toEqual({ valid: true }); + 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'); + }); + + it('rejects --host with existing-endpoint', async () => { + const options: AddGatewayTargetOptions = { + name: 'test-tool', + source: 'existing-endpoint', + endpoint: 'https://example.com/mcp', + host: 'Lambda', + gateway: 'my-gateway', + }; + const result = await validateAddGatewayTargetOptions(options); + expect(result.valid).toBe(false); + expect(result.error).toBe('--host is not applicable for existing endpoint targets'); }); }); @@ -381,4 +614,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 1c94737e..7232f7c7 100644 --- a/src/cli/commands/add/actions.ts +++ b/src/cli/commands/add/actions.ts @@ -23,13 +23,23 @@ 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'; -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'; @@ -54,17 +64,27 @@ export interface ValidatedAddGatewayOptions { discoveryUrl?: string; allowedAudience?: string; allowedClients?: string; + allowedScopes?: string; + agentClientId?: string; + agentClientSecret?: string; agents?: string; } -export interface ValidatedAddMcpToolOptions { +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; gateway?: string; host?: 'Lambda' | 'AgentCoreRuntime'; + outboundAuthType?: 'OAUTH' | 'API_KEY' | 'NONE'; + credentialName?: string; + oauthClientId?: string; + oauthClientSecret?: string; + oauthDiscoveryUrl?: string; + oauthScopes?: string; } export interface ValidatedAddMemoryOptions { @@ -73,10 +93,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 { @@ -148,7 +167,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 }); @@ -231,17 +250,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, }; @@ -259,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, }; } @@ -275,37 +294,65 @@ export async function handleAddGateway(options: ValidatedAddGatewayOptions): Pro } } -// MCP Tool handler -function buildMcpToolConfig(options: ValidatedAddMcpToolOptions): AddMcpToolConfig { +// Gateway Target handler +export function buildGatewayTargetConfig(options: ValidatedAddGatewayTargetOptions): AddGatewayTargetConfig { 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, sourcePath, language: options.language, - exposure: options.exposure, - host: options.exposure === 'mcp-runtime' ? 'AgentCoreRuntime' : options.host!, + source: options.source, + endpoint: options.endpoint, + 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, }; } -export async function handleAddMcpTool(options: ValidatedAddMcpToolOptions): Promise { +export async function handleAddGatewayTarget( + options: ValidatedAddGatewayTargetOptions +): Promise { try { - const config = buildMcpToolConfig(options); + // 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); + 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) { @@ -339,10 +386,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 58a95503..22e89dc5 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'; @@ -58,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) { @@ -77,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, }); @@ -91,9 +99,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 _handleAddMcpToolCLI(options: AddMcpToolOptions): Promise { - const validation = validateAddMcpToolOptions(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 })); @@ -103,20 +110,31 @@ async function _handleAddMcpToolCLI(options: AddMcpToolOptions): Promise { process.exit(1); } - const result = await handleAddMcpTool({ + // 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', - exposure: options.exposure!, - agents: options.agents, 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) { 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}`); } @@ -168,10 +186,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)); @@ -235,38 +265,47 @@ export function registerAdd(program: Command) { await handleAddAgentCLI(options as AddAgentOptions); }); - // Subcommand: add gateway (disabled - coming soon) + // Subcommand: add gateway addCmd - .command('gateway', { hidden: true }) - .description('Add an MCP gateway to the project') + .command('gateway') + .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') .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('--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(() => { - console.error('AgentCore Gateway integration is coming soon.'); - process.exit(1); + .action(async options => { + requireProject(); + await handleAddGatewayCLI(options as AddGatewayOptions); }); - // Subcommand: add mcp-tool (disabled - coming soon) + // Subcommand: add gateway-target addCmd - .command('mcp-tool', { hidden: true }) - .description('Add an MCP tool to the project') + .command('gateway-target') + .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)') - .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('--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(() => { - 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) @@ -290,7 +329,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 f20c3b01..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; } @@ -41,19 +44,26 @@ export interface AddGatewayResult { error?: string; } -// MCP Tool types -export interface AddMcpToolOptions { +// Gateway Target types +export interface AddGatewayTargetOptions { name?: string; description?: string; + type?: string; + 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'; + credentialName?: string; + oauthClientId?: string; + oauthClientSecret?: string; + oauthDiscoveryUrl?: string; + oauthScopes?: string; json?: boolean; } -export interface AddMcpToolResult { +export interface AddGatewayTargetResult { success: boolean; toolName?: string; sourcePath?: string; @@ -77,7 +87,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 7ad3de1c..0aac0a21 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, @@ -7,11 +8,12 @@ import { TargetLanguageSchema, getSupportedModelProviders, } from '../../../schema'; +import { getExistingGateways } from '../../operations/mcp/create-mcp'; import type { AddAgentOptions, AddGatewayOptions, + AddGatewayTargetOptions, AddIdentityOptions, - AddMcpToolOptions, AddMemoryOptions, } from './types'; @@ -25,6 +27,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) { @@ -150,49 +181,132 @@ 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 }; } -// MCP Tool validation -export function validateAddMcpToolOptions(options: AddMcpToolOptions): ValidationResult { +// Gateway Target validation +export async function validateAddGatewayTargetOptions(options: AddGatewayTargetOptions): Promise { if (!options.name) { return { valid: false, error: '--name is required' }; } - if (!options.language) { - return { valid: false, error: '--language is required' }; + if (options.type && options.type !== 'mcpServer' && options.type !== 'lambda') { + return { valid: false, error: 'Invalid type. Valid options: mcpServer, lambda' }; } - if (options.language !== 'Python' && options.language !== 'TypeScript' && options.language !== 'Other') { - return { valid: false, error: 'Invalid language. Valid options: Python, TypeScript, Other' }; + 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.exposure) { - return { valid: false, error: '--exposure is required' }; + // 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'.", + }; } - if (options.exposure !== 'mcp-runtime' && options.exposure !== 'behind-gateway') { - return { valid: false, error: 'Invalid exposure. Use mcp-runtime' }; + // 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.", + }; } - - // Gateway feature is disabled - if (options.exposure === 'behind-gateway') { + if (!existingGateways.includes(options.gateway)) { return { valid: false, - error: "Behind-gateway exposure is coming soon. Use 'mcp-runtime' exposure instead.", + error: `Gateway "${options.gateway}" not found. Available gateways: ${existingGateways.join(', ')}`, }; } - if (options.exposure === 'mcp-runtime') { - if (!options.agents) { - return { valid: false, error: '--agents is required for mcp-runtime exposure' }; + if (options.source === 'existing-endpoint') { + if (options.host) { + return { valid: false, error: '--host is not applicable for existing endpoint targets' }; } - 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' }; + 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'; + + return { valid: true }; + } + + if (!options.language) { + return { valid: false, error: '--language is required' }; + } + + if (options.language !== 'Python' && options.language !== 'TypeScript' && options.language !== 'Other') { + return { valid: false, error: 'Invalid language. Valid options: Python, TypeScript, Other' }; + } + + // Validate outbound auth configuration + if (options.outboundAuthType && options.outboundAuthType !== 'NONE') { + 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: 'Inline OAuth fields cannot be used with API_KEY outbound auth. Use --credential-name instead.', + }; + } + + 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; + } } } @@ -227,6 +341,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/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/commands/deploy/actions.ts b/src/cli/commands/deploy/actions.ts index 537f55b9..6dacc3ef 100644 --- a/src/cli/commands/deploy/actions.ts +++ b/src/cli/commands/deploy/actions.ts @@ -1,7 +1,8 @@ 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 } from '../../cloudformation'; +import { buildDeployedState, getStackOutputs, parseAgentOutputs, parseGatewayOutputs } from '../../cloudformation'; import { getErrorMessage } from '../../errors'; import { ExecLogger } from '../../logging'; import { @@ -10,12 +11,15 @@ import { checkBootstrapNeeded, checkStackDeployability, getAllCredentials, - hasOwnedIdentityApiProviders, + hasIdentityApiProviders, + hasIdentityOAuthProviders, performStackTeardown, setupApiKeyProviders, + setupOAuth2Providers, synthesizeCdk, validateProject, } from '../../operations/deploy'; +import { formatTargetStatus, getGatewayTargetStatuses } from '../../operations/deploy/gateway-status'; import type { DeployResult } from './types'; export interface ValidatedDeployOptions { @@ -64,6 +68,15 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise = {}; + 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; + + // 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...'); + + const identityResult = await setupApiKeyProviders({ + projectSpec: context.projectSpec, + configBaseDir: configIO.getConfigRoot(), + region: target.region, + runtimeCredentials, + enableKmsEncryption: true, + }); + if (identityResult.hasErrors) { + 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() }; + } + identityKmsKeyArn = identityResult.kmsKeyArn; + + // Collect API Key credential ARNs for deployed state + for (const result of identityResult.results) { + if (result.credentialProviderArn) { + deployedCredentials[result.providerName] = { + credentialProviderArn: result.credentialProviderArn, + }; + } + } + endStep('success'); + } + + // Set up OAuth credential providers if needed + if (hasIdentityOAuthProviders(context.projectSpec)) { + startStep('Creating OAuth credentials...'); + + const oauthResult = await setupOAuth2Providers({ + projectSpec: context.projectSpec, + configBaseDir: configIO.getConfigRoot(), + region: target.region, + runtimeCredentials, + }); + if (oauthResult.hasErrors) { + // Log detailed error internally, return sanitized message to avoid leaking OAuth details + const errorResult = oauthResult.results.find(r => r.status === 'error'); + 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() }; + } + + // Collect OAuth credential ARNs for deployed state + for (const result of oauthResult.results) { + if (result.credentialProviderArn) { + deployedCredentials[result.providerName] = { + credentialProviderArn: result.credentialProviderArn, + clientSecretArn: result.clientSecretArn, + callbackUrl: result.callbackUrl, + }; + } + } + endStep('success'); + } + + // Write credential ARNs to deployed state before CDK synth so the template can read them + if (Object.keys(deployedCredentials).length > 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; @@ -155,42 +266,10 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise = {}; - 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 identityResult = await setupApiKeyProviders({ - projectSpec: context.projectSpec, - configBaseDir: configIO.getConfigRoot(), - region: target.region, - runtimeCredentials, - enableKmsEncryption: true, - }); - if (identityResult.hasErrors) { - const errorMsg = identityResult.results.find(r => r.status === 'error')?.error ?? 'Identity setup failed'; - endStep('error', errorMsg); - logger.finalize(false); - return { success: false, error: errorMsg, logPath: logger.getRelativeLogPath() }; - } - identityKmsKeyArn = identityResult.kmsKeyArn; - endStep('success'); - } - // Deploy - startStep('Deploy to AWS'); + const hasGateways = mcpSpec?.agentCoreGateways && mcpSpec.agentCoreGateways.length > 0; + const deployStepName = hasGateways ? 'Deploying gateways...' : 'Deploy to AWS'; + startStep(deployStepName); // Enable verbose output for resource-level events if (switchableIoHost && options.onResourceEvent) { @@ -215,11 +294,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, + deployedCredentials + ); await configIO.writeDeployedState(deployedState); + + // Show gateway URLs and target sync status + if (Object.keys(gateways).length > 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'); logger.finalize(true); @@ -255,7 +372,7 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise { 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/commands/remove/__tests__/remove-gateway-target.test.ts b/src/cli/commands/remove/__tests__/remove-gateway-target.test.ts new file mode 100644 index 00000000..20b65e1e --- /dev/null +++ b/src/cli/commands/remove/__tests__/remove-gateway-target.test.ts @@ -0,0 +1,88 @@ +import { runCLI } from '../../../../test-utils/index.js'; +import { randomUUID } from 'node:crypto'; +import { mkdir, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +// Gateway Target feature is disabled (coming soon) - skip all tests +describe.skip('remove gateway-target command', () => { + let testDir: string; + let projectDir: string; + + beforeAll(async () => { + testDir = join(tmpdir(), `agentcore-remove-gateway-target-${randomUUID()}`); + await mkdir(testDir, { recursive: true }); + + // Create project + const projectName = 'RemoveGatewayTargetProj'; + 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); + }); + + afterAll(async () => { + await rm(testDir, { recursive: true, force: true }); + }); + + describe('validation', () => { + it('requires name flag', async () => { + 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); + expect(json.error.includes('--name'), `Error: ${json.error}`).toBeTruthy(); + }); + + it('rejects non-existent tool', async () => { + 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); + expect(json.error.toLowerCase().includes('not found'), `Error: ${json.error}`).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 () => { + // Create a fresh gateway for this test to avoid conflicts with existing tools + const tempGateway = `TempGw${Date.now()}`; + const gwResult = await runCLI(['add', 'gateway', '--name', tempGateway, '--json'], projectDir); + expect(gwResult.exitCode, `gateway add failed: ${gwResult.stdout}`).toBe(0); + + // Add a tool to the fresh gateway + const tempTool = `tempTool${Date.now()}`; + const addResult = await runCLI( + [ + 'add', + 'gateway-target', + '--name', + tempTool, + '--language', + 'Python', + '--gateway', + tempGateway, + '--host', + 'Lambda', + '--json', + ], + projectDir + ); + expect(addResult.exitCode, `add failed: ${addResult.stdout} ${addResult.stderr}`).toBe(0); + + 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 gateway targets + const mcpSpec = JSON.parse(await readFile(join(projectDir, 'agentcore/mcp.json'), 'utf-8')); + const gateway = mcpSpec.agentCoreGateways?.find((g: { name: string }) => g.name === tempGateway); + const target = gateway?.targets?.find((t: { name: string }) => t.name === tempTool); + expect(!target, 'Tool should be removed from gateway targets').toBeTruthy(); + }); + }); +}); diff --git a/src/cli/commands/remove/__tests__/remove-mcp-tool.test.ts b/src/cli/commands/remove/__tests__/remove-mcp-tool.test.ts deleted file mode 100644 index 0e5e797e..00000000 --- a/src/cli/commands/remove/__tests__/remove-mcp-tool.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { runCLI } from '../../../../test-utils/index.js'; -import { randomUUID } from 'node:crypto'; -import { mkdir, readFile, rm } from 'node:fs/promises'; -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 -describe.skip('remove mcp-tool command', () => { - let testDir: string; - let projectDir: string; - const agentName = 'TestAgent'; - const runtimeToolName = 'RuntimeTool'; - - beforeAll(async () => { - testDir = join(tmpdir(), `agentcore-remove-mcp-tool-${randomUUID()}`); - await mkdir(testDir, { recursive: true }); - - // Create project - const projectName = 'RemoveMcpToolProj'; - 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}`); - } - 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', - 'mcp-tool', - '--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 () => { - await rm(testDir, { recursive: true, force: true }); - }); - - describe('validation', () => { - it('requires name flag', async () => { - const result = await runCLI(['remove', 'mcp-tool', '--json'], projectDir); - expect(result.exitCode).toBe(1); - const json = JSON.parse(result.stdout); - expect(json.success).toBe(false); - expect(json.error.includes('--name'), `Error: ${json.error}`).toBeTruthy(); - }); - - it('rejects non-existent tool', async () => { - const result = await runCLI(['remove', 'mcp-tool', '--name', 'nonexistent', '--json'], projectDir); - expect(result.exitCode).toBe(1); - const json = JSON.parse(result.stdout); - expect(json.success).toBe(false); - expect(json.error.toLowerCase().includes('not found'), `Error: ${json.error}`).toBeTruthy(); - }); - }); - - 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', - 'mcp-tool', - '--name', - tempTool, - '--language', - 'Python', - '--exposure', - 'mcp-runtime', - '--agents', - agentName, - '--json', - ], - projectDir - ); - - const result = await runCLI(['remove', 'mcp-tool', '--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 () => { - // Create a fresh gateway for this test to avoid conflicts with existing tools - const tempGateway = `TempGw${Date.now()}`; - const gwResult = await runCLI(['add', 'gateway', '--name', tempGateway, '--json'], projectDir); - expect(gwResult.exitCode, `gateway add failed: ${gwResult.stdout}`).toBe(0); - - // Add a tool to the fresh gateway - const tempTool = `tempTool${Date.now()}`; - const addResult = await runCLI( - [ - 'add', - 'mcp-tool', - '--name', - tempTool, - '--language', - 'Python', - '--exposure', - 'behind-gateway', - '--gateway', - tempGateway, - '--host', - 'Lambda', - '--json', - ], - projectDir - ); - expect(addResult.exitCode, `add failed: ${addResult.stdout} ${addResult.stderr}`).toBe(0); - - const result = await runCLI(['remove', 'mcp-tool', '--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 gateway targets - const mcpSpec = JSON.parse(await readFile(join(projectDir, 'agentcore/mcp.json'), 'utf-8')); - const gateway = mcpSpec.agentCoreGateways?.find((g: { name: string }) => g.name === tempGateway); - const target = gateway?.targets?.find((t: { name: string }) => t.name === tempTool); - expect(!target, 'Tool should be removed from gateway targets').toBeTruthy(); - }); - }); -}); diff --git a/src/cli/commands/remove/actions.ts b/src/cli/commands/remove/actions.ts index 22518094..74604ea2 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,17 +46,17 @@ 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); + 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, }; } @@ -72,7 +72,7 @@ export async function handleRemove(options: ValidatedRemoveOptions): 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('mcp-tool', { 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 a gateway target 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/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/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..f3bc3617 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,59 @@ 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(); + 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 []; + } +} + /** * 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 +238,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/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/__tests__/pre-deploy-identity.test.ts b/src/cli/operations/deploy/__tests__/pre-deploy-identity.test.ts index 780ec8e4..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,14 +1,30 @@ -import { setupApiKeyProviders } from '../pre-deploy-identity.js'; +import { + getAllCredentials, + hasIdentityOAuthProviders, + 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('hasIdentityOAuthProviders', () => { + it('returns true when OAuthCredentialProvider exists', () => { + const projectSpec = { + credentials: [ + { name: 'oauth-cred', type: 'OAuthCredentialProvider' }, + { name: 'api-cred', type: 'ApiKeyCredentialProvider' }, + ], + }; + expect(hasIdentityOAuthProviders(projectSpec as any)).toBe(true); + }); + + it('returns false when only ApiKey credentials exist', () => { + const projectSpec = { + credentials: [{ name: 'api-cred', type: 'ApiKeyCredentialProvider' }], + }; + expect(hasIdentityOAuthProviders(projectSpec as any)).toBe(false); + }); + + it('returns false when no credentials exist', () => { + const projectSpec = { credentials: [] }; + expect(hasIdentityOAuthProviders(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/deploy/gateway-status.ts b/src/cli/operations/deploy/gateway-status.ts new file mode 100644 index 00000000..815f1115 --- /dev/null +++ b/src/cli/operations/deploy/gateway-status.ts @@ -0,0 +1,44 @@ +/** + * 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/operations/deploy/index.ts b/src/cli/operations/deploy/index.ts index 95897bb3..f5de068e 100644 --- a/src/cli/operations/deploy/index.ts +++ b/src/cli/operations/deploy/index.ts @@ -16,12 +16,17 @@ export { // Pre-deploy identity setup for non-Bedrock model providers export { setupApiKeyProviders, - hasOwnedIdentityApiProviders, + setupOAuth2Providers, + hasIdentityApiProviders, + hasIdentityOAuthProviders, getMissingCredentials, getAllCredentials, type SetupApiKeyProvidersOptions, + type SetupOAuth2ProvidersOptions, type PreDeployIdentityResult, + type PreDeployOAuth2Result, type ApiKeyProviderSetupResult, + type OAuth2ProviderSetupResult, type MissingCredential, } from './pre-deploy-identity'; diff --git a/src/cli/operations/deploy/pre-deploy-identity.ts b/src/cli/operations/deploy/pre-deploy-identity.ts index a24d1c09..dad722b7 100644 --- a/src/cli/operations/deploy/pre-deploy-identity.ts +++ b/src/cli/operations/deploy/pre-deploy-identity.ts @@ -5,6 +5,11 @@ import { isNoCredentialsError } from '../../errors'; import { getAwsLoginGuidance } from '../../external-requirements/checks'; import { apiKeyProviderExists, createApiKeyProvider, setTokenVaultKmsKey, updateApiKeyProvider } from '../identity'; import { computeDefaultCredentialEnvVarName } from '../identity/create-identity'; +import { + createOAuth2Provider, + oAuth2ProviderExists, + updateOAuth2Provider, +} from '../identity/oauth2-credential-provider'; import { BedrockAgentCoreControlClient, GetTokenVaultCommand } from '@aws-sdk/client-bedrock-agentcore-control'; import { CreateKeyCommand, KMSClient } from '@aws-sdk/client-kms'; @@ -15,6 +20,7 @@ import { CreateKeyCommand, KMSClient } from '@aws-sdk/client-kms'; export interface ApiKeyProviderSetupResult { providerName: string; status: 'created' | 'updated' | 'exists' | 'skipped' | 'error'; + credentialProviderArn?: string; error?: string; } @@ -158,6 +164,7 @@ async function setupApiKeyCredentialProvider( return { providerName: credential.name, status: updateResult.success ? 'updated' : 'error', + credentialProviderArn: updateResult.credentialProviderArn, error: updateResult.error, }; } @@ -166,6 +173,7 @@ async function setupApiKeyCredentialProvider( return { providerName: credential.name, status: createResult.success ? 'created' : 'error', + credentialProviderArn: createResult.credentialProviderArn, error: createResult.error, }; } catch (error) { @@ -188,7 +196,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'); } @@ -223,7 +231,7 @@ export async function getMissingCredentials( } /** - * Get list of all API key credentials in the project (for manual entry prompt). + * Get list of all credentials in the project that need env vars (for manual entry prompt and runtime credential reading). */ export function getAllCredentials(projectSpec: AgentCoreProjectSpec): MissingCredential[] { const credentials: MissingCredential[] = []; @@ -234,8 +242,141 @@ export function getAllCredentials(projectSpec: AgentCoreProjectSpec): MissingCre providerName: credential.name, envVarName: computeDefaultCredentialEnvVarName(credential.name), }); + } else if (credential.type === 'OAuthCredentialProvider') { + const nameKey = credential.name.toUpperCase().replace(/-/g, '_'); + credentials.push( + { providerName: credential.name, envVarName: `AGENTCORE_CREDENTIAL_${nameKey}_CLIENT_ID` }, + { providerName: credential.name, envVarName: `AGENTCORE_CREDENTIAL_${nameKey}_CLIENT_SECRET` } + ); } } return credentials; } + +// ───────────────────────────────────────────────────────────────────────────── +// OAuth2 Credential Provider Setup +// ───────────────────────────────────────────────────────────────────────────── + +export interface OAuth2ProviderSetupResult { + providerName: string; + status: 'created' | 'updated' | 'skipped' | 'error'; + error?: string; + credentialProviderArn?: string; + clientSecretArn?: string; + callbackUrl?: string; +} + +export interface SetupOAuth2ProvidersOptions { + projectSpec: AgentCoreProjectSpec; + configBaseDir: string; + region: string; + runtimeCredentials?: SecureCredentials; +} + +export interface PreDeployOAuth2Result { + results: OAuth2ProviderSetupResult[]; + hasErrors: boolean; +} + +/** + * Set up OAuth2 credential providers for all OAuth credentials in the project. + * Reads client credentials from agentcore/.env.local and creates providers in AgentCore Identity. + */ +export async function setupOAuth2Providers(options: SetupOAuth2ProvidersOptions): 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 hasIdentityOAuthProviders(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/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/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/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/__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/identity/api-key-credential-provider.ts b/src/cli/operations/identity/api-key-credential-provider.ts index 97c809c3..36ef8261 100644 --- a/src/cli/operations/identity/api-key-credential-provider.ts +++ b/src/cli/operations/identity/api-key-credential-provider.ts @@ -40,7 +40,7 @@ export async function createApiKeyProvider( client: BedrockAgentCoreControlClient, providerName: string, apiKey: string -): Promise<{ success: boolean; error?: string }> { +): 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/create-identity.ts b/src/cli/operations/identity/create-identity.ts index 0277df94..26a0c672 100644 --- a/src/cli/operations/identity/create-identity.ts +++ b/src/cli/operations/identity/create-identity.ts @@ -4,10 +4,18 @@ 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; + managed?: boolean; + }; /** * Result of resolving credential strategy for an agent. @@ -27,7 +35,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, '_')}`; } /** @@ -101,12 +109,22 @@ 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. - * 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 +133,35 @@ 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 } : {}), + ...(config.managed ? { managed: true } : {}), + }; + 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 +170,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/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..cd037670 --- /dev/null +++ b/src/cli/operations/identity/oauth2-credential-provider.ts @@ -0,0 +1,169 @@ +/** + * 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))); + 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' }; + } + 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))); + 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' }; + } + return { success: true, result }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} diff --git a/src/cli/operations/mcp/__tests__/create-mcp.test.ts b/src/cli/operations/mcp/__tests__/create-mcp.test.ts index d8816eb9..25e5f173 100644 --- a/src/cli/operations/mcp/__tests__/create-mcp.test.ts +++ b/src/cli/operations/mcp/__tests__/create-mcp.test.ts @@ -1,42 +1,176 @@ -import { computeDefaultGatewayEnvVarName, computeDefaultMcpRuntimeEnvVarName } from '../create-mcp.js'; -import { describe, expect, it } from 'vitest'; +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'; -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(), +})); + +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('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('replaces hyphens with underscores', () => { - expect(computeDefaultGatewayEnvVarName('my-gateway')).toBe('AGENTCORE_GATEWAY_MY_GATEWAY_URL'); + it('throws when gateway is not provided', async () => { + const mockMcpSpec = { agentCoreGateways: [{ name: 'test-gateway', targets: [] }] }; + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockResolvedValue(mockMcpSpec); + + await expect(createExternalGatewayTarget(makeExternalConfig({ gateway: undefined }))).rejects.toThrow( + 'Gateway is required' + ); }); - it('uppercases the name', () => { - expect(computeDefaultGatewayEnvVarName('MyGateway')).toBe('AGENTCORE_GATEWAY_MYGATEWAY_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('handles multiple hyphens', () => { - expect(computeDefaultGatewayEnvVarName('my-cool-gateway')).toBe('AGENTCORE_GATEWAY_MY_COOL_GATEWAY_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 already uppercase name', () => { - expect(computeDefaultGatewayEnvVarName('GW')).toBe('AGENTCORE_GATEWAY_GW_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' }); }); }); -describe('computeDefaultMcpRuntimeEnvVarName', () => { - it('converts simple name to env var', () => { - expect(computeDefaultMcpRuntimeEnvVarName('myruntime')).toBe('AGENTCORE_MCPRUNTIME_MYRUNTIME_URL'); +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('replaces hyphens with underscores', () => { - expect(computeDefaultMcpRuntimeEnvVarName('my-runtime')).toBe('AGENTCORE_MCPRUNTIME_MY_RUNTIME_URL'); + it('returns empty array when no mcp config exists', async () => { + mockConfigExists.mockReturnValue(false); + expect(await getUnassignedTargets()).toEqual([]); }); - it('uppercases the name', () => { - expect(computeDefaultMcpRuntimeEnvVarName('MyRuntime')).toBe('AGENTCORE_MCPRUNTIME_MYRUNTIME_URL'); + 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()); - it('handles multiple hyphens', () => { - expect(computeDefaultMcpRuntimeEnvVarName('a-b-c')).toBe('AGENTCORE_MCPRUNTIME_A_B_C_URL'); + 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 a03543e5..f8bb6e63 100644 --- a/src/cli/operations/mcp/create-mcp.ts +++ b/src/cli/operations/mcp/create-mcp.ts @@ -3,16 +3,15 @@ import type { AgentCoreCliMcpDefs, AgentCoreGateway, AgentCoreGatewayTarget, - AgentCoreMcpRuntimeTool, AgentCoreMcpSpec, - CodeZipRuntimeConfig, DirectoryPath, 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 { createCredential } from '../identity/create-identity'; import { existsSync } from 'fs'; import { mkdir, readFile, writeFile } from 'fs/promises'; import { dirname, join } from 'path'; @@ -42,7 +41,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; } @@ -73,10 +72,27 @@ 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 }), }, }; } +/** + * 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. */ @@ -126,7 +142,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); } } @@ -157,31 +173,134 @@ 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); + // 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 }; } -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.`); + throw new Error(`Gateway targets for language "${language}" are not yet supported.`); } } /** - * Create an MCP tool (MCP runtime or behind gateway). + * Validate that a credential name exists in the project spec. */ -export async function createToolFromWizard(config: AddMcpToolConfig): Promise { - validateMcpToolLanguage(config.language); +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 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) { + throw new Error( + "Gateway is required. A gateway target must be attached to a gateway. Create a gateway first with 'agentcore add 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); + + await configIO.writeMcpSpec(mcpSpec); + + return { mcpDefsPath: '', toolName: config.name, projectPath: '' }; +} + +/** + * Create a gateway target (behind gateway only). + */ +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') @@ -198,116 +317,76 @@ export async function createToolFromWizard(config: AddMcpToolConfig): Promise 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', }, - }; + 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(); @@ -325,12 +404,12 @@ export async function createToolFromWizard(config: AddMcpToolConfig): Promise ({ + 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-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/__tests__/remove-identity.test.ts b/src/cli/operations/remove/__tests__/remove-identity.test.ts new file mode 100644 index 00000000..2426b345 --- /dev/null +++ b/src/cli/operations/remove/__tests__/remove-identity.test.ts @@ -0,0 +1,176 @@ +import { previewRemoveCredential, removeCredential } from '../remove-identity.js'; +import { describe, expect, it, vi } from 'vitest'; + +const { mockReadProjectSpec, mockWriteProjectSpec, mockConfigExists, mockReadMcpSpec } = vi.hoisted(() => ({ + mockReadProjectSpec: vi.fn(), + mockWriteProjectSpec: vi.fn(), + mockConfigExists: vi.fn(), + mockReadMcpSpec: vi.fn(), +})); + +vi.mock('../../../../lib/index.js', () => ({ + ConfigIO: class { + readProjectSpec = mockReadProjectSpec; + writeProjectSpec = mockWriteProjectSpec; + 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.' + ); + }); + + 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/index.ts b/src/cli/operations/remove/index.ts index 64268cf1..2930ae73 100644 --- a/src/cli/operations/remove/index.ts +++ b/src/cli/operations/remove/index.ts @@ -1,6 +1,6 @@ export * from './types'; export * from './remove-agent'; export * from './remove-gateway'; -export * from './remove-mcp-tool'; +export * from './remove-gateway-target'; export * from './remove-memory'; export * from './remove-identity'; diff --git a/src/cli/operations/remove/remove-gateway-target.ts b/src/cli/operations/remove/remove-gateway-target.ts new file mode 100644 index 00000000..88fdc004 --- /dev/null +++ b/src/cli/operations/remove/remove-gateway-target.ts @@ -0,0 +1,198 @@ +import { ConfigIO } from '../../../lib'; +import type { AgentCoreCliMcpDefs, AgentCoreMcpSpec } from '../../../schema'; +import type { RemovalPreview, RemovalResult, SchemaChange } from './types'; +import { existsSync } from 'fs'; +import { rm } from 'fs/promises'; +import { join } from 'path'; + +/** + * Represents a gateway target that can be removed. + */ +export interface RemovableGatewayTarget { + name: string; + type: 'gateway-target'; + gatewayName?: string; +} + +/** + * Get list of gateway targets available for removal. + */ +export async function getRemovableGatewayTargets(): Promise { + try { + const configIO = new ConfigIO(); + if (!configIO.configExists('mcp')) { + return []; + } + const mcpSpec = await configIO.readMcpSpec(); + const tools: RemovableGatewayTarget[] = []; + + // Gateway targets + for (const gateway of mcpSpec.agentCoreGateways) { + for (const target of gateway.targets) { + tools.push({ + name: target.name, + type: 'gateway-target', + gatewayName: gateway.name, + }); + } + } + + return tools; + } catch { + return []; + } +} + +/** + * Compute the preview of what will be removed when removing a gateway target. + */ +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: {} }; + + const summary: string[] = []; + const directoriesToDelete: string[] = []; + const schemaChanges: SchemaChange[] = []; + const projectRoot = configIO.getProjectRoot(); + + // 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}`); + } + } + + // Tool definitions in mcp-defs + for (const toolDef of target.toolDefinitions ?? []) { + if (mcpDefs.tools[toolDef.name]) { + summary.push(`Removing tool definition: ${toolDef.name}`); + } + } + + // Compute schema changes + const afterMcpSpec = computeRemovedToolMcpSpec(mcpSpec, tool); + schemaChanges.push({ + file: 'agentcore/mcp.json', + before: mcpSpec, + after: afterMcpSpec, + }); + + const afterMcpDefs = computeRemovedToolMcpDefs(mcpSpec, mcpDefs, tool); + if (JSON.stringify(mcpDefs) !== JSON.stringify(afterMcpDefs)) { + schemaChanges.push({ + file: 'agentcore/mcp-defs.json', + before: mcpDefs, + after: afterMcpDefs, + }); + } + + return { summary, directoriesToDelete, schemaChanges }; +} + +/** + * Compute the MCP spec after removing a tool. + */ +function computeRemovedToolMcpSpec(mcpSpec: AgentCoreMcpSpec, tool: RemovableGatewayTarget): AgentCoreMcpSpec { + // Gateway target + return { + ...mcpSpec, + agentCoreGateways: mcpSpec.agentCoreGateways.map(g => { + if (g.name !== tool.gatewayName) return g; + return { + ...g, + targets: g.targets.filter(t => t.name !== tool.name), + }; + }), + }; +} + +/** + * Compute the MCP defs after removing a tool. + */ +function computeRemovedToolMcpDefs( + mcpSpec: AgentCoreMcpSpec, + mcpDefs: AgentCoreCliMcpDefs, + tool: RemovableGatewayTarget +): AgentCoreCliMcpDefs { + const toolNamesToRemove: string[] = []; + + 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 newTools = { ...mcpDefs.tools }; + for (const name of toolNamesToRemove) { + delete newTools[name]; + } + + return { ...mcpDefs, tools: newTools }; +} + +/** + * Remove a gateway target from the project. + */ +export async function removeGatewayTarget(tool: RemovableGatewayTarget): Promise { + try { + const configIO = new ConfigIO(); + const mcpSpec = await configIO.readMcpSpec(); + const mcpDefs = configIO.configExists('mcpDefs') ? await configIO.readMcpDefs() : { tools: {} }; + const projectRoot = configIO.getProjectRoot(); + + // Find the tool path for deletion + let toolPath: string | undefined; + + 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 + const newMcpSpec = computeRemovedToolMcpSpec(mcpSpec, tool); + await configIO.writeMcpSpec(newMcpSpec); + + // Update MCP defs + const newMcpDefs = computeRemovedToolMcpDefs(mcpSpec, mcpDefs, tool); + await configIO.writeMcpDefs(newMcpDefs); + + // Delete tool directory if it exists + if (toolPath) { + const toolDir = join(projectRoot, toolPath); + if (existsSync(toolDir)) { + await rm(toolDir, { recursive: true, force: true }); + } + } + + return { ok: true }; + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + return { ok: false, error: message }; + } +} 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/operations/remove/remove-identity.ts b/src/cli/operations/remove/remove-identity.ts index 14ed3b4f..6c560c64 100644 --- a/src/cli/operations/remove/remove-identity.ts +++ b/src/cli/operations/remove/remove-identity.ts @@ -42,6 +42,36 @@ 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 = { @@ -61,7 +91,7 @@ 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(); @@ -71,6 +101,39 @@ 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/operations/remove/remove-mcp-tool.ts b/src/cli/operations/remove/remove-mcp-tool.ts deleted file mode 100644 index 9b02be84..00000000 --- a/src/cli/operations/remove/remove-mcp-tool.ts +++ /dev/null @@ -1,251 +0,0 @@ -import { ConfigIO } from '../../../lib'; -import type { AgentCoreCliMcpDefs, AgentCoreMcpSpec } from '../../../schema'; -import type { RemovalPreview, RemovalResult, SchemaChange } from './types'; -import { existsSync } from 'fs'; -import { rm } from 'fs/promises'; -import { join } from 'path'; - -/** - * Represents an MCP tool that can be removed. - */ -export interface RemovableMcpTool { - name: string; - type: 'mcp-runtime' | 'gateway-target'; - gatewayName?: string; -} - -/** - * Get list of MCP tools available for removal. - */ -export async function getRemovableMcpTools(): Promise { - try { - const configIO = new ConfigIO(); - if (!configIO.configExists('mcp')) { - return []; - } - const mcpSpec = await configIO.readMcpSpec(); - const tools: RemovableMcpTool[] = []; - - // MCP Runtime tools - for (const tool of mcpSpec.mcpRuntimeTools ?? []) { - tools.push({ name: tool.name, type: 'mcp-runtime' }); - } - - // Gateway targets - for (const gateway of mcpSpec.agentCoreGateways) { - for (const target of gateway.targets) { - tools.push({ - name: target.name, - type: 'gateway-target', - gatewayName: gateway.name, - }); - } - } - - return tools; - } catch { - return []; - } -} - -/** - * Compute the preview of what will be removed when removing an MCP tool. - */ -export async function previewRemoveMcpTool(tool: RemovableMcpTool): Promise { - const configIO = new ConfigIO(); - const mcpSpec = await configIO.readMcpSpec(); - const mcpDefs = configIO.configExists('mcpDefs') ? await configIO.readMcpDefs() : { tools: {} }; - - const summary: string[] = []; - const directoriesToDelete: string[] = []; - const schemaChanges: SchemaChange[] = []; - const projectRoot = configIO.getProjectRoot(); - - if (tool.type === 'mcp-runtime') { - const mcpTool = mcpSpec.mcpRuntimeTools?.find(t => t.name === tool.name); - if (!mcpTool) { - throw new Error(`MCP Runtime tool "${tool.name}" not found.`); - } - - summary.push(`Removing MCP Runtime tool: ${tool.name}`); - - // 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}`); - } - } - - // 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}`); - } - } - - // Tool definitions in mcp-defs - for (const toolDef of target.toolDefinitions) { - if (mcpDefs.tools[toolDef.name]) { - summary.push(`Removing tool definition: ${toolDef.name}`); - } - } - } - - // Compute schema changes - const afterMcpSpec = computeRemovedToolMcpSpec(mcpSpec, tool); - schemaChanges.push({ - file: 'agentcore/mcp.json', - before: mcpSpec, - after: afterMcpSpec, - }); - - const afterMcpDefs = computeRemovedToolMcpDefs(mcpSpec, mcpDefs, tool); - if (JSON.stringify(mcpDefs) !== JSON.stringify(afterMcpDefs)) { - schemaChanges.push({ - file: 'agentcore/mcp-defs.json', - before: mcpDefs, - after: afterMcpDefs, - }); - } - - return { summary, directoriesToDelete, schemaChanges }; -} - -/** - * Compute the MCP spec after removing a tool. - */ -function computeRemovedToolMcpSpec(mcpSpec: AgentCoreMcpSpec, tool: RemovableMcpTool): AgentCoreMcpSpec { - if (tool.type === 'mcp-runtime') { - return { - ...mcpSpec, - mcpRuntimeTools: (mcpSpec.mcpRuntimeTools ?? []).filter(t => t.name !== tool.name), - }; - } - - // Gateway target - return { - ...mcpSpec, - agentCoreGateways: mcpSpec.agentCoreGateways.map(g => { - if (g.name !== tool.gatewayName) return g; - return { - ...g, - targets: g.targets.filter(t => t.name !== tool.name), - }; - }), - }; -} - -/** - * Compute the MCP defs after removing a tool. - */ -function computeRemovedToolMcpDefs( - mcpSpec: AgentCoreMcpSpec, - mcpDefs: AgentCoreCliMcpDefs, - tool: RemovableMcpTool -): 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 newTools = { ...mcpDefs.tools }; - for (const name of toolNamesToRemove) { - delete newTools[name]; - } - - return { ...mcpDefs, tools: newTools }; -} - -/** - * Remove an MCP tool from the project. - */ -export async function removeMcpTool(tool: RemovableMcpTool): Promise { - try { - const configIO = new ConfigIO(); - const mcpSpec = await configIO.readMcpSpec(); - const mcpDefs = configIO.configExists('mcpDefs') ? await configIO.readMcpDefs() : { tools: {} }; - const projectRoot = configIO.getProjectRoot(); - - // 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; - } - } - - // Update MCP spec - const newMcpSpec = computeRemovedToolMcpSpec(mcpSpec, tool); - await configIO.writeMcpSpec(newMcpSpec); - - // Update MCP defs - const newMcpDefs = computeRemovedToolMcpDefs(mcpSpec, mcpDefs, tool); - await configIO.writeMcpDefs(newMcpDefs); - - // Delete tool directory if it exists - if (toolPath) { - const toolDir = join(projectRoot, toolPath); - if (existsSync(toolDir)) { - await rm(toolDir, { recursive: true, force: true }); - } - } - - return { ok: true }; - } catch (err) { - const message = err instanceof Error ? err.message : 'Unknown error'; - return { ok: false, error: message }; - } -} diff --git a/src/cli/templates/McpToolRenderer.ts b/src/cli/templates/GatewayTargetRenderer.ts similarity index 89% rename from src/cli/templates/McpToolRenderer.ts rename to src/cli/templates/GatewayTargetRenderer.ts index ad15d08f..2680e73c 100644 --- a/src/cli/templates/McpToolRenderer.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,20 +62,20 @@ 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') * @param host - Compute host ('Lambda' or 'AgentCoreRuntime') */ -export async function renderMcpToolTemplate( +export async function renderGatewayTargetTemplate( toolName: string, outputDir: string, language: TargetLanguage, 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/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/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..c9366def 100644 --- a/src/cli/templates/types.ts +++ b/src/cli/templates/types.ts @@ -18,6 +18,21 @@ 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 + /** Credential provider name for @requires_access_token (CUSTOM_JWT only) */ + credentialProviderName?: string; + /** OIDC discovery URL for token endpoint lookup (CUSTOM_JWT only) */ + discoveryUrl?: string; + /** Space-separated scopes for token request (CUSTOM_JWT only) */ + scopes?: string; +} + /** * Configuration needed by template renderers. * This is separate from the v2 Agent schema which only stores runtime config. @@ -29,10 +44,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/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/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..358e1386 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', () => { @@ -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/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/useCdkPreflight.ts b/src/cli/tui/hooks/useCdkPreflight.ts index 70ab6b12..89ed181e 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'; @@ -13,8 +14,10 @@ import { checkStackDeployability, formatError, getAllCredentials, - hasOwnedIdentityApiProviders, + hasIdentityApiProviders, + hasIdentityOAuthProviders, setupApiKeyProviders, + setupOAuth2Providers, synthesizeCdk, validateProject, } from '../../operations/deploy'; @@ -65,6 +68,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; @@ -86,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' }, @@ -98,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 { @@ -119,6 +128,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.) @@ -130,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 }))); }; @@ -354,8 +370,25 @@ 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' }); + updateStepByLabel(LABEL_SYNTH, { status: 'running' }); logger.startStep('Synthesize CloudFormation'); let synthStackNames: string[]; try { @@ -369,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; @@ -385,48 +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' }); - } - - // 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); - 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; + updateStepByLabel(LABEL_STACK_STATUS, { status: 'success' }); } // Check if bootstrap is needed @@ -477,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'); @@ -499,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; @@ -515,58 +617,203 @@ 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; + + // 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); + } + + // 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}`); + } + } + + 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; + } + + logger.endStep('success'); + updateStepByLabel(LABEL_API_KEY, { status: 'success' }); + + for (const result of identityResult.results) { + if (result.credentialProviderArn) { + deployedCredentials[result.providerName] = { + credentialProviderArn: result.credentialProviderArn, + }; + } + } } - // 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 OAuth credential providers if needed + if (hasOAuth) { + updateStepByLabel(LABEL_OAUTH, { 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'); + updateStepByLabel(LABEL_OAUTH, { status: 'error', error: 'Some OAuth providers failed' }); + 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); + Object.assign(deployedCredentials, creds); + + logger.endStep('success'); + updateStepByLabel(LABEL_OAUTH, { status: 'success' }); } - 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 - ) - ); + // 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 (kmsKeyArn) targetState.resources.identityKmsKeyArn = kmsKeyArn; + await configIO.writeDeployedState({ + ...existingState, + targets: { ...existingState.targets, [target!.name]: targetState }, + }); + } + + // Clear runtime credentials + setRuntimeCredentials(null); + + // 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, + 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; } - logger.endStep('success'); - setSteps(prev => prev.map((s, i) => (i === prev.length - 1 ? { ...s, status: 'success' } : s))); - - // Clear runtime credentials - setRuntimeCredentials(null); + // 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) { setBootstrapContext({ - toolkitWrapper: wrapperRef.current!, + toolkitWrapper: wrapperRef.current, target: bootstrapCheck.target, }); setPhase('bootstrap-confirm'); @@ -594,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(() => { @@ -643,6 +890,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { hasCredentialsError, missingCredentials, identityKmsKeyArn, + oauthCredentials, startPreflight, confirmTeardown, cancelTeardown, diff --git a/src/cli/tui/hooks/useCreateMcp.ts b/src/cli/tui/hooks/useCreateMcp.ts index 7d61d8f2..9bef0c75 100644 --- a/src/cli/tui/hooks/useCreateMcp.ts +++ b/src/cli/tui/hooks/useCreateMcp.ts @@ -5,8 +5,9 @@ import { getAvailableAgents, getExistingGateways, getExistingToolNames, + getUnassignedTargets, } 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,17 +39,17 @@ 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); 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 }; } @@ -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/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/cli/tui/hooks/useRemove.ts b/src/cli/tui/hooks/useRemove.ts index f934110c..15d047e0 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); @@ -351,7 +351,7 @@ export function useRemoveIdentity() { const remove = useCallback(async (identityName: string, preview?: RemovalPreview): 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/add/AddFlow.tsx b/src/cli/tui/screens/add/AddFlow.tsx index 236976f3..313b439f 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': @@ -325,7 +325,6 @@ export function AddFlow(props: AddFlowProps) { return ( setFlow({ name: 'select' })} onDev={props.onDev} @@ -334,12 +333,11 @@ export function AddFlow(props: AddFlowProps) { ); } - // MCP Tool wizard - now uses AddMcpToolFlow with mode selection + // Gateway Target wizard - uses AddGatewayTargetFlow if (flow.name === 'tool-wizard') { return ( - setFlow({ name: 'select' })} onDev={props.onDev} @@ -363,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: ('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/add/__tests__/AddScreen.test.tsx b/src/cli/tui/screens/add/__tests__/AddScreen.test.tsx new file mode 100644 index 00000000..714a0ced --- /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('Gateway Target'); + expect(lastFrame()).not.toContain('Add an agent first'); + }); +}); 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/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 c88dd57d..80b975c2 100644 --- a/src/cli/tui/screens/deploy/useDeployFlow.ts +++ b/src/cli/tui/screens/deploy/useDeployFlow.ts @@ -1,9 +1,10 @@ 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'; +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'; @@ -28,6 +29,7 @@ export interface PreSynthesized { stackNames: string[]; switchableIoHost?: SwitchableIoHost; identityKmsKeyArn?: string; + oauthCredentials?: Record; } interface DeployFlowOptions { @@ -44,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; @@ -88,12 +91,14 @@ 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' }); 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) @@ -129,7 +134,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 ?? {}; @@ -163,13 +168,48 @@ 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); const existingState = await configIO.readDeployedState().catch(() => undefined); - const deployedState = buildDeployedState(target.name, currentStackName, agents, existingState, identityKmsKeyArn); + const deployedState = buildDeployedState( + target.name, + currentStackName, + agents, + gateways, + existingState, + identityKmsKeyArn, + Object.keys(oauthCredentials).length > 0 ? oauthCredentials : undefined + ); await configIO.writeDeployedState(deployedState); - }, [context, stackNames, logger, identityKmsKeyArn]); + + // 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 useEffect(() => { @@ -373,6 +413,7 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState deployOutput, deployMessages, stackOutputs, + targetStatuses, hasError, hasTokenExpiredError: combinedTokenExpiredError, hasCredentialsError: preflight.hasCredentialsError, 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); diff --git a/src/cli/tui/screens/identity/AddIdentityFlow.tsx b/src/cli/tui/screens/identity/AddIdentityFlow.tsx index 23061e5f..5240dfb7 100644 --- a/src/cli/tui/screens/identity/AddIdentityFlow.tsx +++ b/src/cli/tui/screens/identity/AddIdentityFlow.tsx @@ -35,7 +35,26 @@ export function AddIdentityFlow({ isInteractive = true, onExit, onBack, onDev, o const handleCreateComplete = useCallback( (config: AddIdentityConfig) => { - void createIdentity(config).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; @@ -59,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/AddGatewayFlow.tsx b/src/cli/tui/screens/mcp/AddGatewayFlow.tsx index 8eb895b3..a285a4bf 100644 --- a/src/cli/tui/screens/mcp/AddGatewayFlow.tsx +++ b/src/cli/tui/screens/mcp/AddGatewayFlow.tsx @@ -1,37 +1,18 @@ -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 { useCreateGateway, useExistingGateways } from '../../hooks/useCreateMcp'; +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 { /** 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 */ @@ -40,78 +21,21 @@ 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, - 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 [flow, setFlow] = useState({ name: 'mode-select' }); - - // Bind flow hooks - const { agents: allAgents, isLoading: isLoadingAgents } = useAgents(); - const { gateways: bindableGateways } = useGateways(); - const { attach: attachGateway } = useAttachGateway(); + const { targets: unassignedTargets } = useUnassignedTargets(); + 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({ @@ -131,208 +55,25 @@ 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 ( 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 ( - ); - } - // Error return ( { resetCreate(); - setFlow({ name: 'mode-select' }); + setFlow({ name: 'create-wizard' }); }} onExit={onExit} /> diff --git a/src/cli/tui/screens/mcp/AddGatewayScreen.tsx b/src/cli/tui/screens/mcp/AddGatewayScreen.tsx index 4577e4c8..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, @@ -23,20 +24,23 @@ interface AddGatewayScreenProps { onComplete: (config: AddGatewayConfig) => void; 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, 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 agentItems: SelectableItem[] = useMemo( - () => availableAgents.map(name => ({ id: name, title: name })), - [availableAgents] + const unassignedTargetItems: SelectableItem[] = useMemo( + () => unassignedTargets.map(name => ({ id: name, title: name })), + [unassignedTargets] ); const authorizerItems: SelectableItem[] = useMemo( @@ -47,7 +51,7 @@ export function AddGatewayScreen({ onComplete, onExit, existingGateways, availab 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'; const authorizerNav = useListNavigation({ @@ -57,12 +61,12 @@ export function AddGatewayScreen({ onComplete, onExit, existingGateways, availab isActive: isAuthorizerStep, }); - const agentsNav = useMultiSelectNavigation({ - items: agentItems, + const targetsNav = useMultiSelectNavigation({ + items: unassignedTargetItems, getId: item => item.id, - onConfirm: ids => wizard.setAgents(ids), + onConfirm: ids => wizard.setSelectedTargets(ids), onExit: () => wizard.goBack(), - isActive: isAgentsStep, + isActive: isIncludeTargetsStep, requireSelection: false, }); @@ -85,12 +89,30 @@ export function AddGatewayScreen({ onComplete, onExit, existingGateways, availab }; 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, availab 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); }; @@ -113,7 +136,7 @@ export function AddGatewayScreen({ onComplete, onExit, existingGateways, availab } }; - const helpText = isAgentsStep + const helpText = isIncludeTargetsStep ? 'Space toggle · Enter confirm · Esc back' : isConfirmStep ? HELP_TEXT.CONFIRM_CANCEL @@ -139,12 +162,19 @@ export function AddGatewayScreen({ onComplete, onExit, existingGateways, availab )} {isAuthorizerStep && ( - + + + {authorizerItems[authorizerNav.selectedIndex]?.id === 'NONE' && ( + + ⚠️ Warning: Gateway will be publicly accessible without authorization + + )} + )} {isJwtConfigStep && ( @@ -153,22 +183,23 @@ export function AddGatewayScreen({ onComplete, onExit, existingGateways, availab onDiscoveryUrl={handleJwtDiscoveryUrl} onAudience={handleJwtAudience} onClients={handleJwtClients} + onScopes={handleJwtScopes} + onAgentClientId={handleJwtAgentClientId} + onAgentClientSecret={handleJwtAgentClientSecret} onCancel={handleJwtCancel} /> )} - {isAgentsStep && - (agentItems.length > 0 ? ( + {isIncludeTargetsStep && + (unassignedTargetItems.length > 0 ? ( ) : ( - - No agents defined. Add agents first via `agentcore add agent`. Press Enter to continue. - + No unassigned targets available. Press Enter to continue. ))} {isConfirmStep && ( @@ -182,9 +213,21 @@ export function AddGatewayScreen({ onComplete, onExit, existingGateways, availab { 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` }] + : []), ] : []), - { label: 'Agents', value: wizard.config.agents.length > 0 ? wizard.config.agents.join(', ') : '(none)' }, + { + label: 'Targets', + value: + wizard.config.selectedTargets && wizard.config.selectedTargets.length > 0 + ? wizard.config.selectedTargets.join(', ') + : '(none)', + }, ]} /> )} @@ -198,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; } @@ -216,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 && ( { @@ -260,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/AddGatewayTargetFlow.tsx b/src/cli/tui/screens/mcp/AddGatewayTargetFlow.tsx new file mode 100644 index 00000000..a840d68e --- /dev/null +++ b/src/cli/tui/screens/mcp/AddGatewayTargetFlow.tsx @@ -0,0 +1,184 @@ +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, 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 }; + +interface AddGatewayTargetFlowProps { + /** Whether running in interactive TUI mode */ + isInteractive?: boolean; + onExit: () => void; + onBack: () => void; + /** Called when user selects dev from success screen to run agent locally */ + onDev?: () => void; + /** Called when user selects deploy from success screen */ + onDeploy?: () => void; +} + +export function AddGatewayTargetFlow({ + isInteractive = true, + onExit, + onBack, + onDev, + onDeploy, +}: AddGatewayTargetFlowProps) { + 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) { + onExit(); + } + }, [isInteractive, flow, onExit]); + + const handleCreateComplete = useCallback( + (config: AddGatewayTargetConfig) => { + setFlow({ + name: 'create-success', + toolName: config.name, + projectPath: '', + loading: true, + loadingMessage: 'Creating gateway target...', + }); + + 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] + ); + + 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 ( + + ); + } + + // Error + return ( + { + resetCreate(); + setFlow({ name: 'create-wizard' }); + }} + onExit={onExit} + /> + ); +} diff --git a/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx b/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx new file mode 100644 index 00000000..169b9633 --- /dev/null +++ b/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx @@ -0,0 +1,208 @@ +import { ToolNameSchema } from '../../../../schema'; +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 type { AddGatewayTargetConfig } 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'; + +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 [outboundAuthType, setOutboundAuthTypeLocal] = useState(null); + + const gatewayItems: SelectableItem[] = useMemo( + () => existingGateways.map(g => ({ id: g, title: g })), + [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[] = 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; + }, [existingOAuthCredentialNames]); + + 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; + + const gatewayNav = useListNavigation({ + items: gatewayItems, + onSelect: item => wizard.setGateway(item.id), + onExit: () => wizard.goBack(), + isActive: isGatewayStep && !noGatewaysAvailable, + }); + + const outboundAuthNav = useListNavigation({ + items: outboundAuthItems, + onSelect: item => { + 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(), + isActive: isOutboundAuthStep && !outboundAuthType, + }); + + const credentialNav = useListNavigation({ + items: credentialItems, + onSelect: item => { + if (item.id === 'create-new') { + onCreateCredential(wizard.config); + } else { + wizard.setOutboundAuth({ type: 'OAUTH', credentialName: item.id }); + } + }, + onExit: () => { + setOutboundAuthTypeLocal(null); + }, + isActive: isOutboundAuthStep && outboundAuthType === 'OAUTH', + }); + + useListNavigation({ + items: [{ id: 'confirm', title: 'Confirm' }], + onSelect: () => onComplete(wizard.config), + onExit: () => { + setOutboundAuthTypeLocal(null); + wizard.goBack(); + }, + isActive: isConfirmStep, + }); + + const helpText = isConfirmStep + ? HELP_TEXT.CONFIRM_CANCEL + : isTextStep + ? HELP_TEXT.TEXT_INPUT + : HELP_TEXT.NAVIGATE_SELECT; + + const headerContent = ; + + return ( + + + {isGatewayStep && !noGatewaysAvailable && ( + + )} + + {noGatewaysAvailable && } + + {isOutboundAuthStep && !outboundAuthType && ( + + )} + + {isOutboundAuthStep && outboundAuthType === 'OAUTH' && ( + + )} + + {isTextStep && ( + (wizard.currentIndex === 0 ? onExit() : wizard.goBack())} + 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 + } + /> + )} + + {isConfirmStep && ( + + )} + + + ); +} + +function NoGatewaysMessage() { + return ( + + No gateways found + Add a gateway first, then attach tools to it. + + Esc back + + + ); +} diff --git a/src/cli/tui/screens/mcp/AddMcpToolFlow.tsx b/src/cli/tui/screens/mcp/AddMcpToolFlow.tsx deleted file mode 100644 index d49f1f89..00000000 --- a/src/cli/tui/screens/mcp/AddMcpToolFlow.tsx +++ /dev/null @@ -1,304 +0,0 @@ -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 { useCreateMcpTool, useExistingGateways, useExistingToolNames } from '../../hooks/useCreateMcp'; -import { AddSuccessScreen } from '../add/AddSuccessScreen'; -import { AddMcpToolScreen } from './AddMcpToolScreen'; -import type { AddMcpToolConfig } from './types'; -import React, { useCallback, useEffect, useMemo, 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 AddMcpToolFlowProps { - /** Whether running in interactive TUI mode */ - isInteractive?: boolean; - /** Available agents */ - existingAgents: string[]; - onExit: () => void; - onBack: () => void; - /** Called when user selects dev from success screen to run agent locally */ - onDev?: () => void; - /** Called when user selects deploy from success screen */ - 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 AddMcpToolFlow({ - isInteractive = true, - existingAgents, - onExit, - onBack, - onDev, - onDeploy, -}: AddMcpToolFlowProps) { - const { createTool, reset: resetCreate } = useCreateMcpTool(); - 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(); - - // 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(); - } - } - }, [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: AddMcpToolConfig) => { - setFlow({ - name: 'create-success', - toolName: config.name, - projectPath: '', - 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 }); - }); - }, - [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 ( - setFlow({ name: 'mode-select' })} - /> - ); - } - - // 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 ( - - ); - } - - // Bind success - if (flow.name === 'bind-success') { - return ( - - ); - } - - // Error - return ( - { - resetCreate(); - setFlow({ name: 'mode-select' }); - }} - onExit={onExit} - /> - ); -} diff --git a/src/cli/tui/screens/mcp/AddMcpToolScreen.tsx b/src/cli/tui/screens/mcp/AddMcpToolScreen.tsx deleted file mode 100644 index 2ffb177d..00000000 --- a/src/cli/tui/screens/mcp/AddMcpToolScreen.tsx +++ /dev/null @@ -1,235 +0,0 @@ -import { ToolNameSchema } from '../../../../schema'; -import { - ConfirmReview, - Panel, - Screen, - StepIndicator, - TextInput, - WizardMultiSelect, - WizardSelect, -} from '../../components'; -import type { SelectableItem } from '../../components'; -import { HELP_TEXT } from '../../constants'; -import { useListNavigation, useMultiSelectNavigation } from '../../hooks'; -import { generateUniqueName } from '../../utils'; -import type { AddMcpToolConfig, ComputeHost, ExposureMode, TargetLanguage } from './types'; -import { COMPUTE_HOST_OPTIONS, EXPOSURE_MODE_OPTIONS, MCP_TOOL_STEP_LABELS, TARGET_LANGUAGE_OPTIONS } from './types'; -import { useAddMcpToolWizard } from './useAddMcpWizard'; -import { Box, Text } from 'ink'; -import React, { useMemo } from 'react'; - -interface AddMcpToolScreenProps { - existingGateways: string[]; - existingAgents: string[]; - existingToolNames: string[]; - onComplete: (config: AddMcpToolConfig) => void; - onExit: () => void; -} - -export function AddMcpToolScreen({ - existingGateways, - existingAgents, - existingToolNames, - onComplete, - onExit, -}: AddMcpToolScreenProps) { - const wizard = useAddMcpToolWizard(existingGateways, existingAgents); - - const languageItems: SelectableItem[] = useMemo( - () => TARGET_LANGUAGE_OPTIONS.map(o => ({ id: o.id, title: o.title, description: o.description })), - [] - ); - - const exposureItems: SelectableItem[] = useMemo( - () => - EXPOSURE_MODE_OPTIONS.map(o => ({ - id: o.id, - title: o.title, - description: o.description, - disabled: 'disabled' in o ? o.disabled : undefined, - })), - [] - ); - - const gatewayItems: SelectableItem[] = useMemo( - () => existingGateways.map(g => ({ id: g, title: g })), - [existingGateways] - ); - - const hostItems: SelectableItem[] = useMemo( - () => COMPUTE_HOST_OPTIONS.map(o => ({ id: o.id, title: o.title, description: o.description })), - [] - ); - - const agentItems: SelectableItem[] = useMemo(() => existingAgents.map(a => ({ id: a, title: a })), [existingAgents]); - - 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 isConfirmStep = wizard.step === 'confirm'; - const noGatewaysAvailable = isGatewayStep && existingGateways.length === 0; - const noAgentsAvailable = isAgentsStep && existingAgents.length === 0; - - const languageNav = useListNavigation({ - items: languageItems, - onSelect: item => wizard.setLanguage(item.id as TargetLanguage), - onExit: () => onExit(), - 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), - onExit: () => wizard.goBack(), - isActive: isGatewayStep && !noGatewaysAvailable, - }); - - const hostNav = useListNavigation({ - items: hostItems, - onSelect: item => wizard.setHost(item.id as ComputeHost), - onExit: () => wizard.goBack(), - 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), - onExit: () => wizard.goBack(), - isActive: isConfirmStep, - }); - - const helpText = isConfirmStep - ? HELP_TEXT.CONFIRM_CANCEL - : isTextStep - ? HELP_TEXT.TEXT_INPUT - : isAgentsStep - ? HELP_TEXT.MULTI_SELECT - : HELP_TEXT.NAVIGATE_SELECT; - - const headerContent = ; - - const isMcpRuntime = wizard.config.exposure === 'mcp-runtime'; - - return ( - - - {isLanguageStep && ( - - )} - - {isExposureStep && ( - - )} - - {isGatewayStep && !noGatewaysAvailable && ( - - )} - - {noGatewaysAvailable && } - - {isAgentsStep && !noAgentsAvailable && ( - - )} - - {noAgentsAvailable && } - - {isHostStep && ( - - )} - - {isTextStep && ( - (wizard.currentIndex === 0 ? onExit() : wizard.goBack())} - schema={ToolNameSchema} - customValidation={value => !existingToolNames.includes(value) || 'Tool name already exists'} - /> - )} - - {isConfirmStep && ( - 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 }, - ]} - /> - )} - - - ); -} - -function NoGatewaysMessage() { - return ( - - No gateways found - Add a gateway first, then attach tools to it. - - Esc back - - - ); -} - -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/__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/mcp/index.ts b/src/cli/tui/screens/mcp/index.ts index 06818162..4f7e44b1 100644 --- a/src/cli/tui/screens/mcp/index.ts +++ b/src/cli/tui/screens/mcp/index.ts @@ -1,14 +1,13 @@ 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..f24aeed5 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' | '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') */ @@ -18,62 +16,79 @@ 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[]; } export const GATEWAY_STEP_LABELS: Record = { name: 'Name', authorizer: 'Authorizer', 'jwt-config': 'JWT Config', - agents: 'Agents', + 'include-targets': 'Include Targets', confirm: 'Confirm', }; // ───────────────────────────────────────────────────────────────────────────── -// MCP Tool Flow Types +// Gateway Target Flow Types // ───────────────────────────────────────────────────────────────────────────── -export type ExposureMode = 'mcp-runtime' | 'behind-gateway'; - export type ComputeHost = 'Lambda' | 'AgentCoreRuntime'; /** - * MCP tool wizard steps. + * Gateway target 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 AddMcpToolStep = 'name' | 'language' | 'exposure' | 'agents' | 'gateway' | 'host' | 'confirm'; +export type AddGatewayTargetStep = + | 'name' + | 'source' + | 'endpoint' + | 'language' + | 'gateway' + | 'host' + | 'outbound-auth' + | 'confirm'; export type TargetLanguage = 'Python' | 'TypeScript' | 'Other'; -export interface AddMcpToolConfig { +export interface AddGatewayTargetConfig { name: string; description: string; sourcePath: string; language: TargetLanguage; - exposure: ExposureMode; - /** Gateway name (only when exposure = behind-gateway) */ + /** Source type for external endpoints */ + source?: 'existing-endpoint' | 'create-new'; + /** External endpoint URL */ + endpoint?: string; + /** 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'; + credentialName?: string; + scopes?: string[]; + }; } -export const MCP_TOOL_STEP_LABELS: Record = { +export const MCP_TOOL_STEP_LABELS: Record = { name: 'Name', + source: 'Source', + endpoint: 'Endpoint', language: 'Language', - exposure: 'Exposure', - agents: 'Agents', gateway: 'Gateway', host: 'Host', + 'outbound-auth': 'Outbound Auth', confirm: 'Confirm', }; @@ -82,8 +97,16 @@ 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 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 = [ @@ -92,21 +115,16 @@ 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 (coming soon)', - description: 'Route through AgentCore Gateway', - disabled: true, - }, -] 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)' }, ] 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 new file mode 100644 index 00000000..d29daaf6 --- /dev/null +++ b/src/cli/tui/screens/mcp/useAddGatewayTargetWizard.ts @@ -0,0 +1,101 @@ +import { APP_DIR, MCP_APP_SUBDIR } from '../../../../lib'; +import type { ToolDefinition } from '../../../../schema'; +import type { AddGatewayTargetConfig, AddGatewayTargetStep } from './types'; +import { useCallback, useMemo, useState } from 'react'; + +/** + * Steps for adding a gateway target (existing endpoint only). + * name → endpoint → gateway → outbound-auth → confirm + */ +function getSteps(): AddGatewayTargetStep[] { + return ['name', 'endpoint', 'gateway', 'outbound-auth', 'confirm']; +} + +function deriveToolDefinition(name: string): ToolDefinition { + return { + name, + description: `Tool for ${name}`, + inputSchema: { type: 'object' }, + }; +} + +function getDefaultConfig(): AddGatewayTargetConfig { + return { + name: '', + description: '', + sourcePath: '', + source: 'existing-endpoint', + language: 'Python', + host: 'Lambda', + toolDefinition: deriveToolDefinition(''), + }; +} + +export function useAddGatewayTargetWizard(existingGateways: string[] = []) { + const [config, setConfig] = useState(getDefaultConfig); + const [step, setStep] = useState('name'); + + const steps = useMemo(() => getSteps(), []); + const currentIndex = steps.indexOf(step); + + const goBack = useCallback(() => { + const currentSteps = getSteps(); + const idx = currentSteps.indexOf(step); + const prevStep = currentSteps[idx - 1]; + if (prevStep) setStep(prevStep); + }, [step]); + + const setName = useCallback((name: string) => { + setConfig(c => ({ + ...c, + name, + description: `Tool for ${name}`, + sourcePath: `${APP_DIR}/${MCP_APP_SUBDIR}/${name}`, + toolDefinition: deriveToolDefinition(name), + })); + setStep('endpoint'); + }, []); + + const setEndpoint = useCallback((endpoint: string) => { + setConfig(c => ({ + ...c, + endpoint, + })); + setStep('gateway'); + }, []); + + const setGateway = useCallback((gateway: string) => { + setConfig(c => ({ ...c, 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'); + }, []); + + return { + config, + step, + steps, + currentIndex, + existingGateways, + goBack, + setName, + setEndpoint, + setGateway, + setOutboundAuth, + reset, + }; +} diff --git a/src/cli/tui/screens/mcp/useAddGatewayWizard.ts b/src/cli/tui/screens/mcp/useAddGatewayWizard.ts index 48c1f0b4..90265bca 100644 --- a/src/cli/tui/screens/mcp/useAddGatewayWizard.ts +++ b/src/cli/tui/screens/mcp/useAddGatewayWizard.ts @@ -4,7 +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', + NONE: 'confirm', + AWS_IAM: 'confirm', CUSTOM_JWT: 'jwt-config', }; @@ -12,23 +13,32 @@ function getDefaultConfig(): AddGatewayConfig { return { name: '', description: '', - 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'); + } + + 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); @@ -58,20 +68,27 @@ export function useAddGatewayWizard() { }, []); 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, })); - setStep('agents'); + setStep(unassignedTargetsCount > 0 ? 'include-targets' : 'confirm'); }, - [] + [unassignedTargetsCount] ); - const setAgents = useCallback((agents: string[]) => { + const setSelectedTargets = useCallback((selectedTargets: string[]) => { setConfig(c => ({ ...c, - agents, + selectedTargets, })); setStep('confirm'); }, []); @@ -90,7 +107,7 @@ export function useAddGatewayWizard() { setName, setAuthorizerType, setJwtConfig, - setAgents, + setSelectedTargets, reset, }; } diff --git a/src/cli/tui/screens/mcp/useAddMcpWizard.ts b/src/cli/tui/screens/mcp/useAddMcpWizard.ts deleted file mode 100644 index 4c32186d..00000000 --- a/src/cli/tui/screens/mcp/useAddMcpWizard.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { APP_DIR, MCP_APP_SUBDIR } from '../../../../lib'; -import type { ToolDefinition } from '../../../../schema'; -import type { AddMcpToolConfig, AddMcpToolStep, ComputeHost, ExposureMode, TargetLanguage } 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 - */ -function getSteps(exposure: ExposureMode): AddMcpToolStep[] { - if (exposure === 'mcp-runtime') { - return ['name', 'language', 'exposure', 'agents', 'confirm']; - } - return ['name', 'language', 'exposure', 'gateway', 'host', 'confirm']; -} - -function deriveToolDefinition(name: string): ToolDefinition { - return { - name, - description: `Tool for ${name}`, - inputSchema: { type: 'object' }, - }; -} - -function getDefaultConfig(): AddMcpToolConfig { - return { - name: '', - description: '', - sourcePath: '', - language: 'Python', - exposure: 'mcp-runtime', - host: 'AgentCoreRuntime', - toolDefinition: deriveToolDefinition(''), - selectedAgents: [], - }; -} - -export function useAddMcpToolWizard(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); - - const goBack = useCallback(() => { - // Recalculate steps in case exposure changed - const currentSteps = getSteps(config.exposure); - const idx = currentSteps.indexOf(step); - const prevStep = currentSteps[idx - 1]; - if (prevStep) setStep(prevStep); - }, [config.exposure, step]); - - const setName = useCallback((name: string) => { - setConfig(c => ({ - ...c, - name, - description: `Tool for ${name}`, - sourcePath: `${APP_DIR}/${MCP_APP_SUBDIR}/${name}`, - toolDefinition: deriveToolDefinition(name), - })); - setStep('language'); - }, []); - - const setLanguage = useCallback((language: TargetLanguage) => { - setConfig(c => ({ - ...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'); - }, []); - - const setGateway = useCallback((gateway: string) => { - setConfig(c => ({ - ...c, - gateway, - })); - setStep('host'); - }, []); - - const setHost = useCallback((host: ComputeHost) => { - setConfig(c => ({ - ...c, - host, - })); - setStep('confirm'); - }, []); - - const reset = useCallback(() => { - setConfig(getDefaultConfig()); - setStep('name'); - }, []); - - return { - config, - step, - steps, - currentIndex, - existingGateways, - existingAgents, - goBack, - setName, - setLanguage, - setExposure, - setAgents, - setGateway, - setHost, - reset, - }; -} diff --git a/src/cli/tui/screens/remove/RemoveFlow.tsx b/src/cli/tui/screens/remove/RemoveFlow.tsx index e2aed558..ae83df72 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); + 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 }); } 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); + 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 }; } 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' })} /> ); } @@ -600,8 +607,8 @@ export function RemoveFlow({ return ( { resetAll(); diff --git a/src/cli/tui/screens/remove/RemoveMcpToolScreen.tsx b/src/cli/tui/screens/remove/RemoveGatewayTargetScreen.tsx similarity index 55% rename from src/cli/tui/screens/remove/RemoveMcpToolScreen.tsx rename to src/cli/tui/screens/remove/RemoveGatewayTargetScreen.tsx index eab5fc67..c7bf5544 100644 --- a/src/cli/tui/screens/remove/RemoveMcpToolScreen.tsx +++ b/src/cli/tui/screens/remove/RemoveGatewayTargetScreen.tsx @@ -1,21 +1,21 @@ -import type { RemovableMcpTool } from '../../../operations/remove'; +import type { RemovableGatewayTarget } from '../../../operations/remove'; import { SelectScreen } from '../../components'; import React from 'react'; -interface RemoveMcpToolScreenProps { - /** List of MCP tools that can be removed */ - tools: RemovableMcpTool[]; +interface RemoveGatewayTargetScreenProps { + /** List of gateway targets that can be removed */ + 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, - description: tool.type === 'mcp-runtime' ? 'MCP Runtime tool' : `Gateway target (${tool.gatewayName})`, + description: `Gateway target (${tool.gatewayName})`, })); // Create a map for quick lookup @@ -23,7 +23,7 @@ export function RemoveMcpToolScreen({ tools, onSelect, onExit }: RemoveMcpToolSc 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 92ff2356..bcb7307c 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: 'mcp-tool', title: 'MCP Tool (coming soon)', description: 'Remove an MCP tool', disabled: true }, + { 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; @@ -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; @@ -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/remove/__tests__/RemoveScreen.test.tsx b/src/cli/tui/screens/remove/__tests__/RemoveScreen.test.tsx new file mode 100644 index 00000000..e1e32e05 --- /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('Gateway Target'); + 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/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/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 f87693e3..8cb2c336 100644 --- a/src/cli/tui/screens/schema/McpGuidedEditor.tsx +++ b/src/cli/tui/screens/schema/McpGuidedEditor.tsx @@ -1,9 +1,10 @@ import { type AgentCoreGateway, - type AgentCoreMcpRuntimeTool, + type AgentCoreGatewayTarget, type AgentCoreMcpSpec, AgentCoreMcpSpecSchema, GatewayNameSchema, + type OutboundAuth, } from '../../../../schema'; import { Header, Panel, ScreenLayout, TextInput } from '../../components'; import { useSchemaDocument } from '../../hooks/useSchemaDocument'; @@ -48,7 +49,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); @@ -74,22 +78,22 @@ 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: { schema: SchemaOption; - initialSpec: AgentCoreMcpSpec; + initialSpec: AgentCoreMcpSpec & { unassignedTargets?: AgentCoreGatewayTarget[] }; baseline: string; onBack: () => void; onSave: (content: string) => Promise<{ ok: boolean; error?: string }>; 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); @@ -101,27 +105,33 @@ function McpEditorBody(props: { // Target editing state const [selectedTargetIndex, setSelectedTargetIndex] = useState(0); const [editingTargetFieldId, setEditingTargetFieldId] = useState(null); - - const hasMcpRuntimeTools = mcpRuntimeTools.length > 0 || (props.initialSpec.mcpRuntimeTools?.length ?? 0) > 0; + // Unassigned target assignment state + const [selectedUnassignedIndex, setSelectedUnassignedIndex] = useState(0); + const [assigningTarget, setAssigningTarget] = useState(false); // 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 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); @@ -132,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') { @@ -198,7 +230,7 @@ function McpEditorBody(props: { return; } if (key.return && targets.length > 0) { - setEditingTargetFieldId('toolName'); + setEditingTargetFieldId('targetName'); setScreenMode('edit-target-field'); return; } @@ -212,6 +244,10 @@ function McpEditorBody(props: { // List mode keys if (key.escape) { + if (assigningTarget) { + setAssigningTarget(false); + return; + } if (expandedIndex !== null) { setExpandedIndex(null); return; @@ -224,14 +260,51 @@ 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); + // 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) { + // 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(); @@ -239,7 +312,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) { @@ -267,13 +340,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); @@ -283,24 +351,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 ( @@ -322,8 +387,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) { @@ -332,51 +396,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 ( @@ -401,7 +449,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 ( @@ -417,7 +465,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 +475,7 @@ function McpEditorBody(props: { {targetName} - ({toolCount} tools · {host}) + ({toolCount} tools · {targetType} · {displayInfo}) ); @@ -439,7 +489,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); @@ -451,17 +501,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) { + if (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 +553,17 @@ function McpEditorBody(props: { { setEditingTargetFieldId(null); @@ -492,9 +577,9 @@ 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')); @@ -542,109 +627,113 @@ 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}} + + {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}) + + + )) + )} - {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}) - - - )) - )} - - )} + )} + + ); + })} + + )} + + + + {/* Unassigned Targets */} + {unassignedTargets.length > 0 && ( + + + + {assigningTarget && ( + + + Assign "{unassignedTargets[selectedUnassignedIndex]?.name}" to gateway: + + + )} + {assigningTarget + ? // Show gateway selection for assignment + gateways.map((gateway, idx) => ( + + {idx === selectedIndex ? '>' : ' '} + {gateway.name} - ); - })} - - )} - - ) : ( - - {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} + )) + : // 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}) - [{tool.compute.host}] - {expanded && ( - - Tool: {tool.toolDefinition?.name ?? '(unnamed)'} - Language: {tool.compute.implementation.language} - {'handler' in tool.compute.implementation && ( - Handler: {tool.compute.implementation.handler} - )} - - )} - - ); - })} - - )} + ); + })} + {!assigningTarget && unassignedTargets.length > 0 && ( + + U select · ←→ navigate · Enter assign + + )} + {assigningTarget && ( + + ↑↓ select gateway · Enter confirm · Esc cancel + + )} + - )} - + + )} {dirty && ( 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 */ 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 cd437fd2..8c95c268 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', + }, }, ], }; @@ -375,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 = { @@ -387,7 +506,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', + }, + }, + ], }, ], }); @@ -401,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); + }); }); diff --git a/src/schema/schemas/agentcore-project.ts b/src/schema/schemas/agentcore-project.ts index 49a7f576..fda34160 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,35 @@ 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(), + /** Whether this credential is used for inbound or outbound auth */ + usage: z.enum(['inbound', 'outbound']).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/deployed-state.ts b/src/schema/schemas/deployed-state.ts index 9438bae5..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; @@ -95,6 +96,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 +116,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(), }); 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({ diff --git a/src/schema/schemas/mcp.ts b/src/schema/schemas/mcp.ts index e3890df1..c5047bf1 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; @@ -354,6 +403,7 @@ export const AgentCoreMcpSpecSchema = z .object({ agentCoreGateways: z.array(AgentCoreGatewaySchema), mcpRuntimeTools: z.array(AgentCoreMcpRuntimeToolSchema).optional(), + unassignedTargets: z.array(AgentCoreGatewayTargetSchema).optional(), }) .strict();