Skip to content
Open
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
37 changes: 14 additions & 23 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 Down Expand Up @@ -265,7 +265,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 +274,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 +404,23 @@ 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 targetPort = getDevPort(project, config.agentName, config.protocol, port, portExplicit);
if (!isMcp && !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) {
throw new ValidationError(
`Port ${fixedPort} is in use. ${config.protocol} agents require port ${fixedPort}.`
);
const actualPort = await findAvailablePort(targetPort);
if (isMcp && actualPort !== targetPort) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now, A2A startup could shift from 9000 to 9001. A later agentcore dev --runtime "" recomputes the original port and still targets 9000. That can fail or send the prompt to the wrong local process. Can we still fail fast?

throw new ValidationError(`Port ${targetPort} is in use. MCP 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 (!isMcp && 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 +431,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
53 changes: 52 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 } 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,57 @@ 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);
});
});

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
22 changes: 22 additions & 0 deletions src/cli/operations/dev/config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ConfigIO, findConfigRoot } from '../../../lib';
import type { AgentCoreProjectSpec, AgentEnvSpec, BuildType, ProtocolMode } from '../../../schema';
import { A2A_DEFAULT_PORT, MCP_DEFAULT_PORT } from './constants';
import { dirname, isAbsolute, join } from 'node:path';

export interface DevConfig {
Expand Down Expand Up @@ -90,6 +91,27 @@ export function getAgentPort(
return index >= 0 ? basePort + index : basePort;
}

/**
* Resolve the local development port for an agent.
*
* A2A starts at its framework default and offsets by runtime index so multiple
* agents can run together. MCP remains fixed because FastMCP currently ignores
* the port environment variable. Explicit ports are honored for all other
* protocols.
*/
export function getDevPort(
project: AgentCoreProjectSpec | null,
agentName: string,
protocol: ProtocolMode,
basePort: number,
explicit = false
): number {
if (protocol === 'MCP') return MCP_DEFAULT_PORT;
if (explicit) return basePort;
const protocolBasePort = protocol === 'A2A' ? A2A_DEFAULT_PORT : basePort;
return getAgentPort(project, agentName, protocolBasePort);
}

/**
* Derives dev server configuration from project config.
* Falls back to sensible defaults if no config is available.
Expand Down
2 changes: 2 additions & 0 deletions src/cli/operations/dev/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const MCP_DEFAULT_PORT = 8000;
export const A2A_DEFAULT_PORT = 9000;
12 changes: 10 additions & 2 deletions src/cli/operations/dev/container-dev-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { CONTAINER_INTERNAL_PORT, DOCKERFILE_NAME, getDockerfilePath } from '../
import { getCustomBuildArgs, getUvBuildArgs } from '../../../lib/packaging/build-args';
import { ensureBuildContextDockerignore } from '../../../lib/packaging/build-context-dockerignore';
import { detectContainerRuntime } from '../../external-requirements/detect';
import { A2A_DEFAULT_PORT, MCP_DEFAULT_PORT } from './constants';
import { DevServer, type LogLevel, type SpawnConfig } from './dev-server';
import { waitForServerReady } from './utils';
import { type ChildProcess, spawn, spawnSync } from 'child_process';
Expand Down Expand Up @@ -177,6 +178,12 @@ export class ContainerDevServer extends DevServer {

protected getSpawnConfig(): SpawnConfig {
const { port, envVars = {} } = this.options;
const internalPort =
this.config.protocol === 'A2A'
? A2A_DEFAULT_PORT
: this.config.protocol === 'MCP'
? MCP_DEFAULT_PORT
: CONTAINER_INTERNAL_PORT;

// Forward AWS credentials from host environment into the container.
// When explicit credentials are present, omit AWS_PROFILE so SDK credential
Expand Down Expand Up @@ -242,7 +249,8 @@ export class ContainerDevServer extends DevServer {
...awsConfigEnv,
...containerEnvVars,
LOCAL_DEV: '1',
PORT: String(CONTAINER_INTERNAL_PORT),
PORT: String(internalPort),
...(this.config.protocol === 'A2A' ? { AGENTCORE_RUNTIME_URL: `http://localhost:${port}/` } : {}),
}).flatMap(([k, v]) => ['-e', `${k}=${v}`]);

return {
Expand All @@ -254,7 +262,7 @@ export class ContainerDevServer extends DevServer {
this.containerName,
...awsMountArgs,
'-p',
`${port}:${CONTAINER_INTERNAL_PORT}`,
`${port}:${internalPort}`,
...envArgs,
this.imageName,
],
Expand Down
9 changes: 8 additions & 1 deletion src/cli/operations/dev/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,14 @@ export {
type DevServerOptions,
} from './server';

export { getDevConfig, getDevSupportedAgents, getAgentPort, loadProjectConfig, type DevConfig } from './config';
export {
getDevConfig,
getDevSupportedAgents,
getAgentPort,
getDevPort,
loadProjectConfig,
type DevConfig,
} from './config';

export { invokeAgent, invokeAgentStreaming, invokeForProtocol } from './invoke';

Expand Down
10 changes: 4 additions & 6 deletions src/cli/operations/dev/web-ui/__tests__/start-port.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,15 @@ describe('resolveAgentTargetPort', () => {
expect(resolveAgentTargetPort({ ...base, protocol: 'HTTP', agentName: 'missing', agentIndex: -1 })).toBe(7778);
});

it('uses framework-fixed ports for A2A and MCP regardless of -p', () => {
it('offsets A2A by runtime index and keeps MCP fixed', () => {
expect(
resolveAgentTargetPort({
...base,
protocol: 'A2A',
agentName: 'A',
agentIndex: 3,
agentBasePort: 8788,
selectedAgent: 'A',
})
).toBe(9000);
).toBe(9003);
expect(
resolveAgentTargetPort({
...base,
Expand All @@ -36,11 +34,11 @@ describe('resolveAgentTargetPort', () => {
).toBe(8000);
});

it('honors an explicit -p literally for the selected runtime (no offset)', () => {
it.each(['HTTP', 'A2A'])('honors an explicit -p literally for a selected %s runtime', protocol => {
expect(
resolveAgentTargetPort({
...base,
protocol: 'HTTP',
protocol,
agentName: 'AgentB',
agentIndex: 1,
agentBasePort: 8788,
Expand Down
Loading
Loading