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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 13 additions & 13 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -823,19 +823,19 @@ agentcore dev list-tools
agentcore dev call-tool --tool myTool --input '{"arg": "value"}'
```

| Flag / Argument | Description |
| ---------------------- | --------------------------------------------------------------------- |
| `[prompt]` | Send a prompt to a running dev server |
| `-p, --port <port>` | Port (default: 8080; MCP uses 8000, A2A uses 9000) |
| `-r, --runtime <name>` | Runtime to run or invoke (required if multiple runtimes) |
| `-s, --stream` | Stream response when invoking |
| `-l, --logs` | Non-interactive stdout logging |
| `--tool <name>` | MCP tool name (with `call-tool` prompt) |
| `--input <json>` | MCP tool arguments as JSON (with `--tool`) |
| `-H, --header <h>` | Custom header (`"Name: Value"`, repeatable) |
| `--exec` | Execute a shell command in the running dev container (Container only) |
| `-b, --no-browser` | Use terminal TUI instead of web-based chat UI |
| `--no-traces` | Disable local OTEL trace collection |
| Flag / Argument | Description |
| ---------------------- | ----------------------------------------------------------------------------------------- |
| `[prompt]` | Send a prompt to a running dev server |
| `-p, --port <port>` | Port (default: 8080; MCP uses 8000; A2A starts at 9000 and offsets for multiple runtimes) |
| `-r, --runtime <name>` | Runtime to run or invoke (required if multiple runtimes) |
| `-s, --stream` | Stream response when invoking |
| `-l, --logs` | Non-interactive stdout logging |
| `--tool <name>` | MCP tool name (with `call-tool` prompt) |
| `--input <json>` | MCP tool arguments as JSON (with `--tool`) |
| `-H, --header <h>` | Custom header (`"Name: Value"`, repeatable) |
| `--exec` | Execute a shell command in the running dev container (Container only) |
| `-b, --no-browser` | Use terminal TUI instead of web-based chat UI |
| `--no-traces` | Disable local OTEL trace collection |

### invoke

Expand Down
3 changes: 3 additions & 0 deletions docs/container-builds.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ For TypeScript agents, the generated `Dockerfile` uses `public.ecr.aws/docker/li
- **Entrypoint**: `npx tsx main.ts` — no compile step, so dev and container runtime share the same entry shape
- **Ports**: Exposes 8080 / 8000 / 9000 to match the HTTP / MCP / A2A contract

During `agentcore dev`, each container receives a unique host port. Multiple A2A agents therefore map ports such as
`9000:9000` and `9001:9000` without conflicting on the host.

Example `agentcore.json` for a TypeScript container agent:

```json
Expand Down
31 changes: 31 additions & 0 deletions src/cli/commands/dev/__tests__/browser-agent-info.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { AgentCoreProjectSpec } from '../../../../schema';
import { getBrowserAgentInfo, getBrowserSelectedAgent } from '../browser-mode';
import { describe, expect, it } from 'vitest';

describe('getBrowserAgentInfo', () => {
it('preserves runtime indexes when unsupported runtimes are filtered out', () => {
const project = {
runtimes: [
{ name: 'unsupported', build: 'Container', protocol: 'HTTP' },
{ name: 'a2a-agent', build: 'CodeZip', protocol: 'A2A', entrypoint: 'main.py' },
],
} as unknown as AgentCoreProjectSpec;

expect(getBrowserAgentInfo(project)).toEqual([
{
name: 'a2a-agent',
buildType: 'CodeZip',
protocol: 'A2A',
runtimeIndex: 1,
},
]);
});

it('selects the only supported runtime so an explicit port applies to it', () => {
const agents = [{ name: 'only-agent', buildType: 'CodeZip', protocol: 'A2A', runtimeIndex: 1 }];

expect(getBrowserSelectedAgent(undefined, agents)).toBe('only-agent');
expect(getBrowserSelectedAgent('requested-agent', agents)).toBe('requested-agent');
expect(getBrowserSelectedAgent(undefined, [...agents, { ...agents[0]!, name: 'second-agent' }])).toBeUndefined();
});
});
29 changes: 18 additions & 11 deletions src/cli/commands/dev/browser-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,21 @@ export interface BrowserModeOptions {
collector?: OtelCollector;
}

export function getBrowserAgentInfo(project: AgentCoreProjectSpec | null): AgentInfo[] {
if (!project) return [];

return getDevSupportedAgents(project).map(agent => ({
name: agent.name,
buildType: agent.build,
protocol: agent.protocol ?? 'HTTP',
runtimeIndex: project.runtimes.findIndex(runtime => runtime.name === agent.name),
}));
}

export function getBrowserSelectedAgent(agentName: string | undefined, agents: AgentInfo[]): string | undefined {
return agentName ?? (agents.length === 1 ? agents[0]?.name : undefined);
}

/**
* Standalone entry point for launching browser dev mode from the TUI.
* Handles all setup (project loading, OTEL collector, etc.) internally.
Expand Down Expand Up @@ -184,11 +199,7 @@ export async function runBrowserMode(opts: BrowserModeOptions): Promise<void> {

const mergedEnvVars = { ...envVars, ...otelEnvVars };

const agentInfoList: AgentInfo[] = supportedAgents.map(a => ({
name: a.name,
buildType: a.build,
protocol: a.protocol ?? 'HTTP',
}));
const agentInfoList = getBrowserAgentInfo(project);

// Resolve deployed resources (memories, agents) so memory browsing and
// CloudWatch traces work in dev mode alongside local traces.
Expand Down Expand Up @@ -237,7 +248,7 @@ export async function runBrowserMode(opts: BrowserModeOptions): Promise<void> {
mode: 'dev',
agents: agentInfoList,
harnesses: harnessInfoList,
selectedAgent: agentName,
selectedAgent: getBrowserSelectedAgent(agentName, agentInfoList),
selectedHarness: harnessName,
agentBasePort: portExplicit ? port : undefined,
envVars: mergedEnvVars,
Expand All @@ -253,11 +264,7 @@ export async function runBrowserMode(opts: BrowserModeOptions): Promise<void> {
reloadAgents: configRoot
? async () => {
const freshProject = await loadProjectConfig(workingDir);
return getDevSupportedAgents(freshProject).map(a => ({
name: a.name,
buildType: a.build,
protocol: a.protocol ?? 'HTTP',
}));
return getBrowserAgentInfo(freshProject);
}
: undefined,
onListTraces: collector
Expand Down
38 changes: 16 additions & 22 deletions src/cli/commands/dev/command.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ import {
callMcpTool,
createDevServer,
findAvailablePort,
getAgentPort,
getDevConfig,
getDevPort,
getDevSupportedAgents,
getEndpointUrl,
invokeAgent,
Expand All @@ -27,6 +27,7 @@ import {
loadDevEnv,
loadProjectConfig,
onShutdownSignal,
requiresExactDevPort,
} from '../../operations/dev';
import { OtelCollector, startOtelCollector } from '../../operations/dev/otel';
import { withCommandRunTelemetry } from '../../telemetry/cli-command-run.js';
Expand Down Expand Up @@ -265,7 +266,6 @@ export const registerDev = (program: Command) => {
let invokePort = port;
let targetAgent = invokeProject?.runtimes[0];
if (opts.runtime && invokeProject) {
invokePort = getAgentPort(invokeProject, opts.runtime, port, portExplicit);
targetAgent = invokeProject.runtimes.find(a => a.name === opts.runtime);
} else if (invokeProject && invokeProject.runtimes.length > 1 && !opts.runtime) {
const names = invokeProject.runtimes.map(a => a.name).join(', ');
Expand All @@ -275,13 +275,13 @@ export const registerDev = (program: Command) => {
}

const protocol = targetAgent?.protocol ?? 'HTTP';
if (targetAgent && invokeProject) {
invokePort = getDevPort(invokeProject, targetAgent.name, protocol, port, portExplicit);
}
recorder.set({
agent_protocol: standardize(AgentProtocol, protocol.toLowerCase()),
});

if (protocol === 'A2A') invokePort = 9000;
else if (protocol === 'MCP') invokePort = 8000;

if (protocol === 'MCP') {
await handleMcpInvoke(invokePort, invokePrompt, opts.tool, opts.input, headers);
} else if (protocol === 'A2A') {
Expand Down Expand Up @@ -405,31 +405,25 @@ export const registerDev = (program: Command) => {
agent_protocol: standardize(AgentProtocol, config.protocol.toLowerCase()),
});

const isA2A = config.protocol === 'A2A';
const isMcp = config.protocol === 'MCP';
const isHttp = !isA2A && !isMcp;
const fixedPort = isA2A
? 9000
: isMcp
? 8000
: getAgentPort(project, config.agentName, port, portExplicit);
if (isHttp && !portExplicit && fixedPort !== port) {
const requiresExactPort = requiresExactDevPort(config.protocol);
const targetPort = getDevPort(project, config.agentName, config.protocol, port, portExplicit);
if (config.protocol !== 'MCP' && !portExplicit && targetPort !== port) {
const idx = project.runtimes.findIndex(a => a.name === config.agentName);
console.log(
`Runtime "${config.agentName}" is at index ${idx}; using port ${fixedPort} (pass --port ${fixedPort} to override).`
`Runtime "${config.agentName}" is at index ${idx}; using port ${targetPort} (pass --port ${targetPort} to override).`
);
}
const actualPort = await findAvailablePort(fixedPort);
if ((isA2A || isMcp) && actualPort !== fixedPort) {
const actualPort = await findAvailablePort(targetPort);
if (requiresExactPort && actualPort !== targetPort) {
throw new ValidationError(
`Port ${fixedPort} is in use. ${config.protocol} agents require port ${fixedPort}.`
`Port ${targetPort} is in use. ${config.protocol} agents require port ${targetPort}.`
);
}
// An explicit -p must be honored literally; if it's taken, fail fast instead of
// silently rebinding to a different port (the silent-shift behavior #1079 removes).
if (isHttp && portExplicit && actualPort !== fixedPort) {
if (!requiresExactPort && portExplicit && actualPort !== targetPort) {
throw new ValidationError(
`Port ${fixedPort} is in use. Free it or pass a different --port (no port is chosen automatically when --port is set explicitly).`
`Port ${targetPort} is in use. Free it or pass a different --port (no port is chosen automatically when --port is set explicitly).`
);
}

Expand All @@ -440,8 +434,8 @@ export const registerDev = (program: Command) => {

const logger = new ExecLogger({ command: 'dev' });

if (actualPort !== fixedPort) {
console.log(`Port ${fixedPort} in use, using ${actualPort}`);
if (actualPort !== targetPort) {
console.log(`Port ${targetPort} in use, using ${actualPort}`);
}

console.log(`Starting dev server...`);
Expand Down
4 changes: 3 additions & 1 deletion src/cli/operations/dev/__tests__/codezip-dev-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ const defaultOptions: DevServerOptions = { port: 8080, envVars: { MY_KEY: 'secre

describe('CodeZipDevServer spawn config', () => {
beforeEach(() => {
mockSpawn.mockClear();
mockSpawn.mockReturnValue(createMockChildProcess());
});

Expand Down Expand Up @@ -106,7 +107,7 @@ describe('CodeZipDevServer spawn config', () => {
);
});

it('non-HTTP: passes env vars including PORT and LOCAL_DEV', async () => {
it('A2A: passes the selected port and agent-card URL in the environment', async () => {
const config: DevConfig = {
agentName: 'A2aAgent',
module: 'main.py',
Expand All @@ -123,6 +124,7 @@ describe('CodeZipDevServer spawn config', () => {
const spawnCall = mockSpawn.mock.calls[0]!;
const env = spawnCall[2].env;
expect(env.PORT).toBe('8080');
expect(env.AGENTCORE_RUNTIME_URL).toBe('http://localhost:8080/');
expect(env.LOCAL_DEV).toBe('1');
expect(env.MY_KEY).toBe('secret');
});
Expand Down
59 changes: 58 additions & 1 deletion src/cli/operations/dev/__tests__/config.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { AgentCoreProjectSpec, DirectoryPath, FilePath } from '../../../../schema';
import { getAgentPort, getDevConfig, getDevSupportedAgents } from '../config';
import { getAgentPort, getDevConfig, getDevPort, getDevSupportedAgents, requiresExactDevPort } from '../config';
import { describe, expect, it } from 'vitest';

// Helper to cast strings to branded path types for testing
Expand Down Expand Up @@ -671,6 +671,63 @@ describe('getAgentPort', () => {
});
});

describe('getDevPort', () => {
const project: AgentCoreProjectSpec = {
name: 'TestProject',
version: 1,
managedBy: 'CDK' as const,
runtimes: [
{
name: 'AgentA',
build: 'CodeZip',
runtimeVersion: 'PYTHON_3_12',
entrypoint: filePath('main.py'),
codeLocation: dirPath('./agents/a'),
protocol: 'A2A',
},
{
name: 'AgentB',
build: 'CodeZip',
runtimeVersion: 'PYTHON_3_12',
entrypoint: filePath('main.py'),
codeLocation: dirPath('./agents/b'),
protocol: 'A2A',
},
],
memories: [],
knowledgeBases: [],
credentials: [],
evaluators: [],
onlineEvalConfigs: [],
agentCoreGateways: [],
policyEngines: [],
configBundles: [],
abTests: [],
harnesses: [],
datasets: [],
payments: [],
};

it('offsets the A2A default port by runtime index', () => {
expect(getDevPort(project, 'AgentA', 'A2A', 8080)).toBe(9000);
expect(getDevPort(project, 'AgentB', 'A2A', 8080)).toBe(9001);
});

it('honors an explicit port for A2A', () => {
expect(getDevPort(project, 'AgentB', 'A2A', 8788, true)).toBe(8788);
});

it('keeps MCP on its fixed framework port', () => {
expect(getDevPort(project, 'AgentB', 'MCP', 8788, true)).toBe(8000);
});

it('requires A2A and MCP to bind their computed ports', () => {
expect(requiresExactDevPort('A2A')).toBe(true);
expect(requiresExactDevPort('MCP')).toBe(true);
expect(requiresExactDevPort('HTTP')).toBe(false);
});
});

describe('getDevSupportedAgents', () => {
it('returns empty array when project is null', () => {
expect(getDevSupportedAgents(null)).toEqual([]);
Expand Down
27 changes: 27 additions & 0 deletions src/cli/operations/dev/__tests__/container-dev-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,33 @@ describe('ContainerDevServer', () => {
expect(spawnArgs).toContain(`9000:${CONTAINER_INTERNAL_PORT}`);
});

it('maps a unique A2A host port to the A2A container port', async () => {
mockSuccessfulPrepare();
const config = { ...defaultConfig, protocol: 'A2A' as const };
const options = { ...defaultOptions, port: 9001 };

const server = new ContainerDevServer(config, options);
await server.start();

const spawnArgs = getSpawnArgs();
expect(spawnArgs).toContain('9001:9000');
expect(spawnArgs).toContain('PORT=9000');
expect(spawnArgs).toContain('AGENTCORE_RUNTIME_URL=http://localhost:9001/');
});

it('maps an MCP host port to the MCP container port', async () => {
mockSuccessfulPrepare();
const config = { ...defaultConfig, protocol: 'MCP' as const };
const options = { ...defaultOptions, port: 8000 };

const server = new ContainerDevServer(config, options);
await server.start();

const spawnArgs = getSpawnArgs();
expect(spawnArgs).toContain('8000:8000');
expect(spawnArgs).toContain('PORT=8000');
});

it('includes user-provided environment variables', async () => {
mockSuccessfulPrepare();

Expand Down
3 changes: 3 additions & 0 deletions src/cli/operations/dev/codezip-dev-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,9 @@ export class CodeZipDevServer extends DevServer {
if (protocol === 'MCP') {
env.FASTMCP_PORT = String(port);
}
if (protocol === 'A2A') {
env.AGENTCORE_RUNTIME_URL = `http://localhost:${port}/`;
}

if (!isPython) {
// TS entrypoint is already a file path like "main.ts" — pass it straight to tsx.
Expand Down
Loading
Loading