diff --git a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap index 89ef1df5..bdd7c567 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 +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..28f2aef3 --- /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 +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/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', () => { 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..b7e6a62c 100644 --- a/src/cli/commands/add/validate.ts +++ b/src/cli/commands/add/validate.ts @@ -198,6 +198,9 @@ export async function validateAddGatewayTargetOptions(options: AddGatewayTargetO } if (options.source === 'existing-endpoint') { + if (options.host) { + return { valid: false, error: '--host is not applicable for existing endpoint targets' }; + } if (!options.endpoint) { return { valid: false, error: '--endpoint is required when source is existing-endpoint' }; } @@ -218,6 +221,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..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' : 'lambda', + targetType: 'mcpServer', toolDefinitions: toolDefs, compute: config.host === 'Lambda' 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 }); 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..97d5b7a7 100644 --- a/src/schema/schemas/__tests__/mcp.test.ts +++ b/src/schema/schemas/__tests__/mcp.test.ts @@ -492,6 +492,19 @@ describe('AgentCoreGatewayTargetSchema with outbound auth', () => { }); expect(result.success).toBe(false); }); + + it('mcpServer target with compute (scaffolded) passes', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'myTarget', + targetType: 'mcpServer', + compute: { + host: 'Lambda', + implementation: { language: 'Python', path: 'app/mcp/myTarget', handler: 'handler.lambda_handler' }, + pythonVersion: 'PYTHON_3_12', + }, + }); + expect(result.success).toBe(true); + }); }); describe('AgentCoreMcpSpecSchema', () => {