From af05e8d91ccc1b3a0a84b3cb05dc16c63a10fae2 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 24 Feb 2026 13:38:15 -0500 Subject: [PATCH 1/5] feat: add FastMCP Lambda template for MCP server scaffolding Add python-fastmcp-lambda template for scaffolding MCP servers that run on AWS Lambda with function URLs. Uses FastMCP for tool definitions and Mangum as the ASGI-to-Lambda adapter. Template includes sample HTTP tools (lookup_ip, get_random_user, fetch_post) matching the existing python/ template patterns. Updates GatewayTargetRenderer to select this template when the compute host is Lambda. --- .../assets.snapshot.test.ts.snap | 174 ++++++++++++++++++ .../mcp/python-fastmcp-lambda/README.md | 27 +++ .../mcp/python-fastmcp-lambda/handler.py | 114 ++++++++++++ .../mcp/python-fastmcp-lambda/pyproject.toml | 18 ++ src/cli/templates/GatewayTargetRenderer.ts | 2 +- 5 files changed, 334 insertions(+), 1 deletion(-) create mode 100644 src/assets/mcp/python-fastmcp-lambda/README.md create mode 100644 src/assets/mcp/python-fastmcp-lambda/handler.py create mode 100644 src/assets/mcp/python-fastmcp-lambda/pyproject.toml diff --git a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap index 89ef1df5..3c1a48fb 100644 --- a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap +++ b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap @@ -395,6 +395,9 @@ exports[`Assets Directory Snapshots > File listing > should match the expected f "cdk/tsconfig.json", "container/python/Dockerfile", "container/python/dockerignore.template", + "mcp/python-fastmcp-lambda/README.md", + "mcp/python-fastmcp-lambda/handler.py", + "mcp/python-fastmcp-lambda/pyproject.toml", "mcp/python-lambda/README.md", "mcp/python-lambda/handler.py", "mcp/python-lambda/pyproject.toml", @@ -631,6 +634,177 @@ if __name__ == "__main__": " `; +exports[`Assets Directory Snapshots > MCP assets > mcp/mcp/python-fastmcp-lambda/README.md should match snapshot 1`] = ` +"# {{ name }} + +FastMCP server running on AWS Lambda with a function URL, generated by the AgentCore CLI. + +Demonstrates HTTP tool patterns with proper error handling and retry logic. + +## How It Works + +This server uses [FastMCP](https://github.com/jlowin/fastmcp) to define MCP tools and +[Mangum](https://github.com/jordanh/mangum) to adapt the ASGI app for AWS Lambda. +The Lambda function URL provides the HTTP endpoint that the AgentCore gateway connects to. + +## Available Tools + +| Tool | Description | +| ----------------- | ------------------------------------------------------ | +| \`lookup_ip\` | Look up geolocation and network info for an IP address | +| \`get_random_user\` | Generate a random user profile for testing | +| \`fetch_post\` | Fetch a post by ID from JSONPlaceholder API | + +## Deployment + +\`\`\`bash +agentcore deploy +\`\`\` + +The CDK stack creates the Lambda function, function URL, and wires it to the gateway target. +" +`; + +exports[`Assets Directory Snapshots > MCP assets > mcp/mcp/python-fastmcp-lambda/handler.py should match snapshot 1`] = ` +"""" +FastMCP Server for AWS Lambda with Function URL. + +This template shows: +- FastMCP server running on Lambda via Mangum ASGI adapter +- HTTP tool patterns with proper error handling +- Retry logic and response validation + +Deploy with: agentcore deploy +""" + +import logging +from typing import Any + +import httpx +from mangum import Mangum +from mcp.server.fastmcp import FastMCP + +logging.basicConfig(level=logging.INFO, format="%(levelname)s - %(message)s") +logger = logging.getLogger(__name__) + +mcp = FastMCP("tools") + +HTTP_TIMEOUT = 10.0 +MAX_RETRIES = 2 + + +async def fetch_json(url: str, headers: dict[str, str] | None = None) -> dict[str, Any] | None: + """Make an HTTP GET request with retry logic.""" + async with httpx.AsyncClient() as client: + for attempt in range(MAX_RETRIES): + try: + response = await client.get(url, headers=headers, timeout=HTTP_TIMEOUT) + response.raise_for_status() + return response.json() + except httpx.TimeoutException: + logger.warning(f"Timeout on attempt {attempt + 1} for {url}") + except httpx.HTTPStatusError as e: + logger.error(f"HTTP {e.response.status_code} for {url}") + return None + except httpx.RequestError as e: + logger.error(f"Request failed: {e}") + return None + return None + + +@mcp.tool() +async def lookup_ip(ip_address: str) -> str: + """Look up geolocation and network info for an IP address. + + Args: + ip_address: IPv4 or IPv6 address to look up + """ + data = await fetch_json(f"http://ip-api.com/json/{ip_address}") + + if not data: + return f"Failed to look up IP: {ip_address}" + + if data.get("status") == "fail": + return f"Lookup failed: {data.get('message', 'unknown error')}" + + return ( + f"IP: {data['query']}\\n" + f"Location: {data['city']}, {data['regionName']}, {data['country']}\\n" + f"ISP: {data['isp']}\\n" + f"Organization: {data['org']}\\n" + f"Timezone: {data['timezone']}" + ) + + +@mcp.tool() +async def get_random_user() -> str: + """Generate a random user profile for testing or mock data.""" + data = await fetch_json("https://randomuser.me/api/") + + if not data or "results" not in data: + return "Failed to generate random user." + + user = data["results"][0] + name = user["name"] + location = user["location"] + + return ( + f"Name: {name['first']} {name['last']}\\n" + f"Email: {user['email']}\\n" + f"Location: {location['city']}, {location['country']}\\n" + f"Phone: {user['phone']}" + ) + + +@mcp.tool() +async def fetch_post(post_id: int) -> str: + """Fetch a post by ID from JSONPlaceholder API. + + Args: + post_id: The post ID (1-100) + """ + if not 1 <= post_id <= 100: + return "Post ID must be between 1 and 100." + + data = await fetch_json(f"https://jsonplaceholder.typicode.com/posts/{post_id}") + + if not data: + return f"Failed to fetch post {post_id}." + + return ( + f"Post #{data['id']}\\n" + f"Title: {data['title']}\\n\\n" + f"{data['body']}" + ) + + +# Create ASGI app from FastMCP server and wrap with Mangum for Lambda +handler = Mangum(mcp.sse_app(), lifespan="off") +" +`; + +exports[`Assets Directory Snapshots > MCP assets > mcp/mcp/python-fastmcp-lambda/pyproject.toml should match snapshot 1`] = ` +"[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "{{ name }}" +version = "0.1.0" +description = "FastMCP Server on AWS Lambda" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "mcp[cli] >= 1.2.0", + "httpx >= 0.27.0", + "mangum >= 0.19.0", +] + +[tool.hatch.build.targets.wheel] +packages = ["."] +" +`; + exports[`Assets Directory Snapshots > MCP assets > mcp/mcp/python-lambda/README.md should match snapshot 1`] = ` "# {{ Name }} diff --git a/src/assets/mcp/python-fastmcp-lambda/README.md b/src/assets/mcp/python-fastmcp-lambda/README.md new file mode 100644 index 00000000..4a707fe1 --- /dev/null +++ b/src/assets/mcp/python-fastmcp-lambda/README.md @@ -0,0 +1,27 @@ +# {{ name }} + +FastMCP server running on AWS Lambda with a function URL, generated by the AgentCore CLI. + +Demonstrates HTTP tool patterns with proper error handling and retry logic. + +## How It Works + +This server uses [FastMCP](https://github.com/jlowin/fastmcp) to define MCP tools and +[Mangum](https://github.com/jordanh/mangum) to adapt the ASGI app for AWS Lambda. +The Lambda function URL provides the HTTP endpoint that the AgentCore gateway connects to. + +## Available Tools + +| Tool | Description | +| ----------------- | ------------------------------------------------------ | +| `lookup_ip` | Look up geolocation and network info for an IP address | +| `get_random_user` | Generate a random user profile for testing | +| `fetch_post` | Fetch a post by ID from JSONPlaceholder API | + +## Deployment + +```bash +agentcore deploy +``` + +The CDK stack creates the Lambda function, function URL, and wires it to the gateway target. diff --git a/src/assets/mcp/python-fastmcp-lambda/handler.py b/src/assets/mcp/python-fastmcp-lambda/handler.py new file mode 100644 index 00000000..fb6554cc --- /dev/null +++ b/src/assets/mcp/python-fastmcp-lambda/handler.py @@ -0,0 +1,114 @@ +""" +FastMCP Server for AWS Lambda with Function URL. + +This template shows: +- FastMCP server running on Lambda via Mangum ASGI adapter +- HTTP tool patterns with proper error handling +- Retry logic and response validation + +Deploy with: agentcore deploy +""" + +import logging +from typing import Any + +import httpx +from mangum import Mangum +from mcp.server.fastmcp import FastMCP + +logging.basicConfig(level=logging.INFO, format="%(levelname)s - %(message)s") +logger = logging.getLogger(__name__) + +mcp = FastMCP("tools") + +HTTP_TIMEOUT = 10.0 +MAX_RETRIES = 2 + + +async def fetch_json(url: str, headers: dict[str, str] | None = None) -> dict[str, Any] | None: + """Make an HTTP GET request with retry logic.""" + async with httpx.AsyncClient() as client: + for attempt in range(MAX_RETRIES): + try: + response = await client.get(url, headers=headers, timeout=HTTP_TIMEOUT) + response.raise_for_status() + return response.json() + except httpx.TimeoutException: + logger.warning(f"Timeout on attempt {attempt + 1} for {url}") + except httpx.HTTPStatusError as e: + logger.error(f"HTTP {e.response.status_code} for {url}") + return None + except httpx.RequestError as e: + logger.error(f"Request failed: {e}") + return None + return None + + +@mcp.tool() +async def lookup_ip(ip_address: str) -> str: + """Look up geolocation and network info for an IP address. + + Args: + ip_address: IPv4 or IPv6 address to look up + """ + data = await fetch_json(f"http://ip-api.com/json/{ip_address}") + + if not data: + return f"Failed to look up IP: {ip_address}" + + if data.get("status") == "fail": + return f"Lookup failed: {data.get('message', 'unknown error')}" + + return ( + f"IP: {data['query']}\n" + f"Location: {data['city']}, {data['regionName']}, {data['country']}\n" + f"ISP: {data['isp']}\n" + f"Organization: {data['org']}\n" + f"Timezone: {data['timezone']}" + ) + + +@mcp.tool() +async def get_random_user() -> str: + """Generate a random user profile for testing or mock data.""" + data = await fetch_json("https://randomuser.me/api/") + + if not data or "results" not in data: + return "Failed to generate random user." + + user = data["results"][0] + name = user["name"] + location = user["location"] + + return ( + f"Name: {name['first']} {name['last']}\n" + f"Email: {user['email']}\n" + f"Location: {location['city']}, {location['country']}\n" + f"Phone: {user['phone']}" + ) + + +@mcp.tool() +async def fetch_post(post_id: int) -> str: + """Fetch a post by ID from JSONPlaceholder API. + + Args: + post_id: The post ID (1-100) + """ + if not 1 <= post_id <= 100: + return "Post ID must be between 1 and 100." + + data = await fetch_json(f"https://jsonplaceholder.typicode.com/posts/{post_id}") + + if not data: + return f"Failed to fetch post {post_id}." + + return ( + f"Post #{data['id']}\n" + f"Title: {data['title']}\n\n" + f"{data['body']}" + ) + + +# Create ASGI app from FastMCP server and wrap with Mangum for Lambda +handler = Mangum(mcp.sse_app(), lifespan="off") diff --git a/src/assets/mcp/python-fastmcp-lambda/pyproject.toml b/src/assets/mcp/python-fastmcp-lambda/pyproject.toml new file mode 100644 index 00000000..ba5b0d8d --- /dev/null +++ b/src/assets/mcp/python-fastmcp-lambda/pyproject.toml @@ -0,0 +1,18 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "{{ name }}" +version = "0.1.0" +description = "FastMCP Server on AWS Lambda" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "mcp[cli] >= 1.2.0", + "httpx >= 0.27.0", + "mangum >= 0.19.0", +] + +[tool.hatch.build.targets.wheel] +packages = ["."] diff --git a/src/cli/templates/GatewayTargetRenderer.ts b/src/cli/templates/GatewayTargetRenderer.ts index 12e25f4d..3505a16a 100644 --- a/src/cli/templates/GatewayTargetRenderer.ts +++ b/src/cli/templates/GatewayTargetRenderer.ts @@ -79,7 +79,7 @@ export async function renderGatewayTargetTemplate( } // Select template based on compute host - const templateSubdir = host === 'Lambda' ? 'python-lambda' : 'python'; + const templateSubdir = host === 'Lambda' ? 'python-fastmcp-lambda' : 'python'; const templateDir = getTemplatePath('mcp', templateSubdir); await copyAndRenderDir(templateDir, outputDir, { Name: toolName }); From 80e1df6c97bd9e84fb2b88488406be56b81035a4 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 24 Feb 2026 14:41:54 -0500 Subject: [PATCH 2/5] fix: rename handler to lambda_handler to match DEFAULT_HANDLER The CLI writes handler: 'handler.lambda_handler' in compute config. The template must export lambda_handler, not handler. --- src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap | 2 +- src/assets/mcp/python-fastmcp-lambda/handler.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap index 3c1a48fb..bdd7c567 100644 --- a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap +++ b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap @@ -779,7 +779,7 @@ async def fetch_post(post_id: int) -> str: # Create ASGI app from FastMCP server and wrap with Mangum for Lambda -handler = Mangum(mcp.sse_app(), lifespan="off") +lambda_handler = Mangum(mcp.sse_app(), lifespan="off") " `; diff --git a/src/assets/mcp/python-fastmcp-lambda/handler.py b/src/assets/mcp/python-fastmcp-lambda/handler.py index fb6554cc..28f2aef3 100644 --- a/src/assets/mcp/python-fastmcp-lambda/handler.py +++ b/src/assets/mcp/python-fastmcp-lambda/handler.py @@ -111,4 +111,4 @@ async def fetch_post(post_id: int) -> str: # Create ASGI app from FastMCP server and wrap with Mangum for Lambda -handler = Mangum(mcp.sse_app(), lifespan="off") +lambda_handler = Mangum(mcp.sse_app(), lifespan="off") From 8308c68306793ef4c49b54038c275f81f6b53b69 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 24 Feb 2026 14:15:55 -0500 Subject: [PATCH 3/5] feat: wire scaffold MCP server flow with Lambda-only host Route CLI gateway-target command based on source: existing-endpoint calls createExternalGatewayTarget, create-new calls createToolFromWizard. Add mcpServerScaffold targetType for scaffolded Lambda MCP servers, distinguishing them from external mcpServer endpoints and raw lambda targets. CDK support for mcpServerScaffold comes in Task 15. Restrict scaffold flow to Lambda-only compute host: - TUI: skip host selection step (always Lambda) - CLI: reject --host AgentCoreRuntime for create-new - CLI: reject --host with existing-endpoint Default --source to create-new and --host to Lambda when not specified. --- src/cli/commands/add/actions.ts | 10 +++++++- src/cli/commands/add/validate.ts | 21 +++++++++++++++-- src/cli/operations/mcp/create-mcp.ts | 2 +- .../screens/mcp/useAddGatewayTargetWizard.ts | 10 +++----- src/schema/schemas/__tests__/mcp.test.ts | 23 ++++++++++++++++++- src/schema/schemas/mcp.ts | 15 +++++++++++- 6 files changed, 68 insertions(+), 13 deletions(-) diff --git a/src/cli/commands/add/actions.ts b/src/cli/commands/add/actions.ts index 0d52fd3f..8f4aabf4 100644 --- a/src/cli/commands/add/actions.ts +++ b/src/cli/commands/add/actions.ts @@ -23,7 +23,11 @@ import { createCredential, resolveCredentialStrategy, } from '../../operations/identity/create-identity'; -import { createGatewayFromWizard, createToolFromWizard } from '../../operations/mcp/create-mcp'; +import { + createExternalGatewayTarget, + createGatewayFromWizard, + createToolFromWizard, +} from '../../operations/mcp/create-mcp'; import { createMemory } from '../../operations/memory/create-memory'; import { createRenderer } from '../../templates'; import type { MemoryOption } from '../../tui/screens/generate/types'; @@ -342,6 +346,10 @@ export async function handleAddGatewayTarget( } const config = buildGatewayTargetConfig(options); + if (config.source === 'existing-endpoint') { + const result = await createExternalGatewayTarget(config); + return { success: true, toolName: result.toolName }; + } const result = await createToolFromWizard(config); return { success: true, toolName: result.toolName, sourcePath: result.projectPath }; } catch (err) { diff --git a/src/cli/commands/add/validate.ts b/src/cli/commands/add/validate.ts index d7cbc802..47978a97 100644 --- a/src/cli/commands/add/validate.ts +++ b/src/cli/commands/add/validate.ts @@ -189,8 +189,13 @@ export async function validateAddGatewayTargetOptions(options: AddGatewayTargetO return { valid: false, error: '--name is required' }; } - if (options.type && options.type !== 'mcpServer' && options.type !== 'lambda') { - return { valid: false, error: 'Invalid type. Valid options: mcpServer, lambda' }; + if ( + options.type && + options.type !== 'mcpServer' && + options.type !== 'mcpServerScaffold' && + options.type !== 'lambda' + ) { + return { valid: false, error: 'Invalid type. Valid options: mcpServer, mcpServerScaffold, lambda' }; } if (options.source && options.source !== 'existing-endpoint' && options.source !== 'create-new') { @@ -198,6 +203,9 @@ export async function validateAddGatewayTargetOptions(options: AddGatewayTargetO } if (options.source === 'existing-endpoint') { + if (options.host) { + return { valid: false, error: '--host is not applicable for existing endpoint targets' }; + } if (!options.endpoint) { return { valid: false, error: '--endpoint is required when source is existing-endpoint' }; } @@ -218,6 +226,15 @@ export async function validateAddGatewayTargetOptions(options: AddGatewayTargetO return { valid: true }; } + // Default source to create-new when not specified (scaffold flow) + options.source ??= 'create-new'; + // Default host to Lambda for scaffolded targets + options.host ??= 'Lambda'; + + if (options.host !== 'Lambda') { + return { valid: false, error: 'Only Lambda is supported as compute host for scaffolded targets' }; + } + if (!options.language) { return { valid: false, error: '--language is required' }; } diff --git a/src/cli/operations/mcp/create-mcp.ts b/src/cli/operations/mcp/create-mcp.ts index d6e36a1f..d4baf74b 100644 --- a/src/cli/operations/mcp/create-mcp.ts +++ b/src/cli/operations/mcp/create-mcp.ts @@ -345,7 +345,7 @@ export async function createToolFromWizard(config: AddGatewayTargetConfig): Prom // Create a single target with all tool definitions const target: AgentCoreGatewayTarget = { name: config.name, - targetType: config.host === 'AgentCoreRuntime' ? 'mcpServer' : 'lambda', + targetType: config.host === 'AgentCoreRuntime' ? 'mcpServer' : 'mcpServerScaffold', toolDefinitions: toolDefs, compute: config.host === 'Lambda' diff --git a/src/cli/tui/screens/mcp/useAddGatewayTargetWizard.ts b/src/cli/tui/screens/mcp/useAddGatewayTargetWizard.ts index 29aa54ac..c92498eb 100644 --- a/src/cli/tui/screens/mcp/useAddGatewayTargetWizard.ts +++ b/src/cli/tui/screens/mcp/useAddGatewayTargetWizard.ts @@ -13,7 +13,8 @@ function getSteps(source?: 'existing-endpoint' | 'create-new'): AddGatewayTarget if (source === 'existing-endpoint') { return ['name', 'source', 'endpoint', 'gateway', 'confirm']; } - return ['name', 'source', 'language', 'gateway', 'host', 'confirm']; + // Phase 1: Lambda is the only compute host, so skip host selection + return ['name', 'source', 'language', 'gateway', 'confirm']; } function deriveToolDefinition(name: string): ToolDefinition { @@ -91,13 +92,8 @@ export function useAddGatewayTargetWizard(existingGateways: string[] = []) { const setGateway = useCallback((gateway: string) => { setConfig(c => { - const isExternal = c.source === 'existing-endpoint'; const isSkipped = gateway === SKIP_FOR_NOW; - if (isExternal || isSkipped) { - setStep('confirm'); - } else { - setStep('host'); - } + setStep('confirm'); return { ...c, gateway: isSkipped ? undefined : gateway }; }); }, []); diff --git a/src/schema/schemas/__tests__/mcp.test.ts b/src/schema/schemas/__tests__/mcp.test.ts index 8c95c268..bdb30474 100644 --- a/src/schema/schemas/__tests__/mcp.test.ts +++ b/src/schema/schemas/__tests__/mcp.test.ts @@ -14,7 +14,7 @@ import { import { describe, expect, it } from 'vitest'; describe('GatewayTargetTypeSchema', () => { - it.each(['lambda', 'mcpServer', 'openApiSchema', 'smithyModel'])('accepts "%s"', type => { + it.each(['lambda', 'mcpServer', 'mcpServerScaffold', 'openApiSchema', 'smithyModel'])('accepts "%s"', type => { expect(GatewayTargetTypeSchema.safeParse(type).success).toBe(true); }); @@ -492,6 +492,27 @@ describe('AgentCoreGatewayTargetSchema with outbound auth', () => { }); expect(result.success).toBe(false); }); + + it('mcpServerScaffold target without compute fails', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'myTarget', + targetType: 'mcpServerScaffold', + }); + expect(result.success).toBe(false); + }); + + it('mcpServerScaffold target with compute passes', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'myTarget', + targetType: 'mcpServerScaffold', + compute: { + host: 'Lambda', + implementation: { language: 'Python', path: 'app/mcp/myTarget', handler: 'handler.handler' }, + pythonVersion: 'PYTHON_3_12', + }, + }); + expect(result.success).toBe(true); + }); }); describe('AgentCoreMcpSpecSchema', () => { diff --git a/src/schema/schemas/mcp.ts b/src/schema/schemas/mcp.ts index c5047bf1..3f1fd32d 100644 --- a/src/schema/schemas/mcp.ts +++ b/src/schema/schemas/mcp.ts @@ -8,7 +8,13 @@ import { z } from 'zod'; // MCP-Specific Schemas // ============================================================================ -export const GatewayTargetTypeSchema = z.enum(['lambda', 'mcpServer', 'openApiSchema', 'smithyModel']); +export const GatewayTargetTypeSchema = z.enum([ + 'lambda', + 'mcpServer', + 'mcpServerScaffold', + 'openApiSchema', + 'smithyModel', +]); export type GatewayTargetType = z.infer; // ============================================================================ @@ -307,6 +313,13 @@ export const AgentCoreGatewayTargetSchema = z path: ['toolDefinitions'], }); } + if (data.targetType === 'mcpServerScaffold' && !data.compute) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Scaffolded MCP Server targets require compute configuration.', + path: ['compute'], + }); + } if (data.outboundAuth && data.outboundAuth.type !== 'NONE' && !data.outboundAuth.credentialName) { ctx.addIssue({ code: z.ZodIssueCode.custom, From 7a413f1b14826b89eefb1935a5f84309c0d98cfc Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 24 Feb 2026 14:23:34 -0500 Subject: [PATCH 4/5] test: add validation and routing tests for scaffold flow Test source/host defaults, Lambda-only enforcement, --host rejection for existing-endpoint, and handleAddGatewayTarget routing based on source type. --- .../commands/add/__tests__/actions.test.ts | 63 ++++++++++++++++++- .../commands/add/__tests__/validate.test.ts | 41 ++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/src/cli/commands/add/__tests__/actions.test.ts b/src/cli/commands/add/__tests__/actions.test.ts index 852523bc..84cc4713 100644 --- a/src/cli/commands/add/__tests__/actions.test.ts +++ b/src/cli/commands/add/__tests__/actions.test.ts @@ -1,6 +1,15 @@ import { buildGatewayTargetConfig } from '../actions.js'; import type { ValidatedAddGatewayTargetOptions } from '../actions.js'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const mockCreateToolFromWizard = vi.fn().mockResolvedValue({ toolName: 'test', projectPath: '/tmp' }); +const mockCreateExternalGatewayTarget = vi.fn().mockResolvedValue({ toolName: 'test', projectPath: '' }); + +vi.mock('../../../operations/mcp/create-mcp', () => ({ + createToolFromWizard: (...args: unknown[]) => mockCreateToolFromWizard(...args), + createExternalGatewayTarget: (...args: unknown[]) => mockCreateExternalGatewayTarget(...args), + createGatewayFromWizard: vi.fn(), +})); describe('buildGatewayTargetConfig', () => { it('maps name, gateway, language correctly', () => { @@ -66,3 +75,55 @@ describe('buildGatewayTargetConfig', () => { expect(config.outboundAuth).toBeUndefined(); }); }); + +// Dynamic import to pick up mocks +const { handleAddGatewayTarget } = await import('../actions.js'); + +describe('handleAddGatewayTarget', () => { + afterEach(() => vi.clearAllMocks()); + + it('routes existing-endpoint to createExternalGatewayTarget', async () => { + const options: ValidatedAddGatewayTargetOptions = { + name: 'test-tool', + language: 'Other', + host: 'Lambda', + source: 'existing-endpoint', + endpoint: 'https://example.com/mcp', + gateway: 'my-gw', + }; + + await handleAddGatewayTarget(options); + + expect(mockCreateExternalGatewayTarget).toHaveBeenCalledOnce(); + expect(mockCreateToolFromWizard).not.toHaveBeenCalled(); + }); + + it('routes create-new to createToolFromWizard', async () => { + const options: ValidatedAddGatewayTargetOptions = { + name: 'test-tool', + language: 'Python', + host: 'Lambda', + source: 'create-new', + gateway: 'my-gw', + }; + + await handleAddGatewayTarget(options); + + expect(mockCreateToolFromWizard).toHaveBeenCalledOnce(); + expect(mockCreateExternalGatewayTarget).not.toHaveBeenCalled(); + }); + + it('routes to createToolFromWizard when source not specified', async () => { + const options: ValidatedAddGatewayTargetOptions = { + name: 'test-tool', + language: 'Python', + host: 'Lambda', + gateway: 'my-gw', + }; + + await handleAddGatewayTarget(options); + + expect(mockCreateToolFromWizard).toHaveBeenCalledOnce(); + expect(mockCreateExternalGatewayTarget).not.toHaveBeenCalled(); + }); +}); diff --git a/src/cli/commands/add/__tests__/validate.test.ts b/src/cli/commands/add/__tests__/validate.test.ts index e9a7992a..4e66f32b 100644 --- a/src/cli/commands/add/__tests__/validate.test.ts +++ b/src/cli/commands/add/__tests__/validate.test.ts @@ -422,6 +422,47 @@ describe('validate', () => { expect(result.valid).toBe(false); expect(result.error).toContain('--credential-name is required'); }); + + // Source and host defaults + it('defaults source to create-new when not specified', async () => { + const options: AddGatewayTargetOptions = { name: 'test-tool', language: 'Python' }; + const result = await validateAddGatewayTargetOptions(options); + expect(result.valid).toBe(true); + expect(options.source).toBe('create-new'); + }); + + it('defaults host to Lambda when not specified', async () => { + const options: AddGatewayTargetOptions = { name: 'test-tool', language: 'Python' }; + const result = await validateAddGatewayTargetOptions(options); + expect(result.valid).toBe(true); + expect(options.host).toBe('Lambda'); + }); + + // Lambda-only enforcement + it('rejects AgentCoreRuntime host for create-new', async () => { + const options: AddGatewayTargetOptions = { + name: 'test-tool', + language: 'Python', + source: 'create-new', + host: 'AgentCoreRuntime', + }; + const result = await validateAddGatewayTargetOptions(options); + expect(result.valid).toBe(false); + expect(result.error).toBe('Only Lambda is supported as compute host for scaffolded targets'); + }); + + // Host rejected for existing-endpoint + it('rejects --host with existing-endpoint', async () => { + const options: AddGatewayTargetOptions = { + name: 'test-tool', + source: 'existing-endpoint', + endpoint: 'https://example.com/mcp', + host: 'Lambda', + }; + const result = await validateAddGatewayTargetOptions(options); + expect(result.valid).toBe(false); + expect(result.error).toBe('--host is not applicable for existing endpoint targets'); + }); }); describe('validateAddMemoryOptions', () => { From 571c8fd0769b96d83230ffa25b874a594b8b39c7 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 24 Feb 2026 14:48:24 -0500 Subject: [PATCH 5/5] refactor: use mcpServer targetType for scaffolded targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove mcpServerScaffold — unnecessary complexity. CDK can distinguish scaffolded vs external targets by checking compute vs endpoint fields: - mcpServer + endpoint = external MCP server - mcpServer + compute = scaffolded, deploy Lambda + function URL --- src/cli/commands/add/validate.ts | 9 ++------- src/cli/operations/mcp/create-mcp.ts | 2 +- src/schema/schemas/__tests__/mcp.test.ts | 16 ++++------------ src/schema/schemas/mcp.ts | 15 +-------------- 4 files changed, 8 insertions(+), 34 deletions(-) diff --git a/src/cli/commands/add/validate.ts b/src/cli/commands/add/validate.ts index 47978a97..b7e6a62c 100644 --- a/src/cli/commands/add/validate.ts +++ b/src/cli/commands/add/validate.ts @@ -189,13 +189,8 @@ export async function validateAddGatewayTargetOptions(options: AddGatewayTargetO return { valid: false, error: '--name is required' }; } - if ( - options.type && - options.type !== 'mcpServer' && - options.type !== 'mcpServerScaffold' && - options.type !== 'lambda' - ) { - return { valid: false, error: 'Invalid type. Valid options: mcpServer, mcpServerScaffold, lambda' }; + if (options.type && options.type !== 'mcpServer' && options.type !== 'lambda') { + return { valid: false, error: 'Invalid type. Valid options: mcpServer, lambda' }; } if (options.source && options.source !== 'existing-endpoint' && options.source !== 'create-new') { diff --git a/src/cli/operations/mcp/create-mcp.ts b/src/cli/operations/mcp/create-mcp.ts index d4baf74b..1d814067 100644 --- a/src/cli/operations/mcp/create-mcp.ts +++ b/src/cli/operations/mcp/create-mcp.ts @@ -345,7 +345,7 @@ export async function createToolFromWizard(config: AddGatewayTargetConfig): Prom // Create a single target with all tool definitions const target: AgentCoreGatewayTarget = { name: config.name, - targetType: config.host === 'AgentCoreRuntime' ? 'mcpServer' : 'mcpServerScaffold', + targetType: 'mcpServer', toolDefinitions: toolDefs, compute: config.host === 'Lambda' diff --git a/src/schema/schemas/__tests__/mcp.test.ts b/src/schema/schemas/__tests__/mcp.test.ts index bdb30474..97d5b7a7 100644 --- a/src/schema/schemas/__tests__/mcp.test.ts +++ b/src/schema/schemas/__tests__/mcp.test.ts @@ -14,7 +14,7 @@ import { import { describe, expect, it } from 'vitest'; describe('GatewayTargetTypeSchema', () => { - it.each(['lambda', 'mcpServer', 'mcpServerScaffold', 'openApiSchema', 'smithyModel'])('accepts "%s"', type => { + it.each(['lambda', 'mcpServer', 'openApiSchema', 'smithyModel'])('accepts "%s"', type => { expect(GatewayTargetTypeSchema.safeParse(type).success).toBe(true); }); @@ -493,21 +493,13 @@ describe('AgentCoreGatewayTargetSchema with outbound auth', () => { expect(result.success).toBe(false); }); - it('mcpServerScaffold target without compute fails', () => { + it('mcpServer target with compute (scaffolded) passes', () => { const result = AgentCoreGatewayTargetSchema.safeParse({ name: 'myTarget', - targetType: 'mcpServerScaffold', - }); - expect(result.success).toBe(false); - }); - - it('mcpServerScaffold target with compute passes', () => { - const result = AgentCoreGatewayTargetSchema.safeParse({ - name: 'myTarget', - targetType: 'mcpServerScaffold', + targetType: 'mcpServer', compute: { host: 'Lambda', - implementation: { language: 'Python', path: 'app/mcp/myTarget', handler: 'handler.handler' }, + implementation: { language: 'Python', path: 'app/mcp/myTarget', handler: 'handler.lambda_handler' }, pythonVersion: 'PYTHON_3_12', }, }); diff --git a/src/schema/schemas/mcp.ts b/src/schema/schemas/mcp.ts index 3f1fd32d..c5047bf1 100644 --- a/src/schema/schemas/mcp.ts +++ b/src/schema/schemas/mcp.ts @@ -8,13 +8,7 @@ import { z } from 'zod'; // MCP-Specific Schemas // ============================================================================ -export const GatewayTargetTypeSchema = z.enum([ - 'lambda', - 'mcpServer', - 'mcpServerScaffold', - 'openApiSchema', - 'smithyModel', -]); +export const GatewayTargetTypeSchema = z.enum(['lambda', 'mcpServer', 'openApiSchema', 'smithyModel']); export type GatewayTargetType = z.infer; // ============================================================================ @@ -313,13 +307,6 @@ export const AgentCoreGatewayTargetSchema = z path: ['toolDefinitions'], }); } - if (data.targetType === 'mcpServerScaffold' && !data.compute) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Scaffolded MCP Server targets require compute configuration.', - path: ['compute'], - }); - } if (data.outboundAuth && data.outboundAuth.type !== 'NONE' && !data.outboundAuth.credentialName) { ctx.addIssue({ code: z.ZodIssueCode.custom,