Skip to content

Commit 705e2ea

Browse files
authored
fix(dev): support multiple A2A runtime ports (#1818)
* fix(dev): support multiple A2A runtime ports * fix(dev): keep A2A port resolution consistent
1 parent 9d041fa commit 705e2ea

17 files changed

Lines changed: 269 additions & 89 deletions

docs/commands.md

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -823,19 +823,19 @@ agentcore dev list-tools
823823
agentcore dev call-tool --tool myTool --input '{"arg": "value"}'
824824
```
825825

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

840840
### invoke
841841

docs/container-builds.md

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

62+
During `agentcore dev`, each container receives a unique host port. Multiple A2A agents therefore map ports such as
63+
`9000:9000` and `9001:9000` without conflicting on the host.
64+
6265
Example `agentcore.json` for a TypeScript container agent:
6366

6467
```json
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import type { AgentCoreProjectSpec } from '../../../../schema';
2+
import { getBrowserAgentInfo, getBrowserSelectedAgent } from '../browser-mode';
3+
import { describe, expect, it } from 'vitest';
4+
5+
describe('getBrowserAgentInfo', () => {
6+
it('preserves runtime indexes when unsupported runtimes are filtered out', () => {
7+
const project = {
8+
runtimes: [
9+
{ name: 'unsupported', build: 'Container', protocol: 'HTTP' },
10+
{ name: 'a2a-agent', build: 'CodeZip', protocol: 'A2A', entrypoint: 'main.py' },
11+
],
12+
} as unknown as AgentCoreProjectSpec;
13+
14+
expect(getBrowserAgentInfo(project)).toEqual([
15+
{
16+
name: 'a2a-agent',
17+
buildType: 'CodeZip',
18+
protocol: 'A2A',
19+
runtimeIndex: 1,
20+
},
21+
]);
22+
});
23+
24+
it('selects the only supported runtime so an explicit port applies to it', () => {
25+
const agents = [{ name: 'only-agent', buildType: 'CodeZip', protocol: 'A2A', runtimeIndex: 1 }];
26+
27+
expect(getBrowserSelectedAgent(undefined, agents)).toBe('only-agent');
28+
expect(getBrowserSelectedAgent('requested-agent', agents)).toBe('requested-agent');
29+
expect(getBrowserSelectedAgent(undefined, [...agents, { ...agents[0]!, name: 'second-agent' }])).toBeUndefined();
30+
});
31+
});

src/cli/commands/dev/browser-mode.ts

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,21 @@ export interface BrowserModeOptions {
115115
collector?: OtelCollector;
116116
}
117117

118+
export function getBrowserAgentInfo(project: AgentCoreProjectSpec | null): AgentInfo[] {
119+
if (!project) return [];
120+
121+
return getDevSupportedAgents(project).map(agent => ({
122+
name: agent.name,
123+
buildType: agent.build,
124+
protocol: agent.protocol ?? 'HTTP',
125+
runtimeIndex: project.runtimes.findIndex(runtime => runtime.name === agent.name),
126+
}));
127+
}
128+
129+
export function getBrowserSelectedAgent(agentName: string | undefined, agents: AgentInfo[]): string | undefined {
130+
return agentName ?? (agents.length === 1 ? agents[0]?.name : undefined);
131+
}
132+
118133
/**
119134
* Standalone entry point for launching browser dev mode from the TUI.
120135
* Handles all setup (project loading, OTEL collector, etc.) internally.
@@ -184,11 +199,7 @@ export async function runBrowserMode(opts: BrowserModeOptions): Promise<void> {
184199

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

187-
const agentInfoList: AgentInfo[] = supportedAgents.map(a => ({
188-
name: a.name,
189-
buildType: a.build,
190-
protocol: a.protocol ?? 'HTTP',
191-
}));
202+
const agentInfoList = getBrowserAgentInfo(project);
192203

193204
// Resolve deployed resources (memories, agents) so memory browsing and
194205
// CloudWatch traces work in dev mode alongside local traces.
@@ -237,7 +248,7 @@ export async function runBrowserMode(opts: BrowserModeOptions): Promise<void> {
237248
mode: 'dev',
238249
agents: agentInfoList,
239250
harnesses: harnessInfoList,
240-
selectedAgent: agentName,
251+
selectedAgent: getBrowserSelectedAgent(agentName, agentInfoList),
241252
selectedHarness: harnessName,
242253
agentBasePort: portExplicit ? port : undefined,
243254
envVars: mergedEnvVars,
@@ -253,11 +264,7 @@ export async function runBrowserMode(opts: BrowserModeOptions): Promise<void> {
253264
reloadAgents: configRoot
254265
? async () => {
255266
const freshProject = await loadProjectConfig(workingDir);
256-
return getDevSupportedAgents(freshProject).map(a => ({
257-
name: a.name,
258-
buildType: a.build,
259-
protocol: a.protocol ?? 'HTTP',
260-
}));
267+
return getBrowserAgentInfo(freshProject);
261268
}
262269
: undefined,
263270
onListTraces: collector

src/cli/commands/dev/command.tsx

Lines changed: 16 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ import {
1616
callMcpTool,
1717
createDevServer,
1818
findAvailablePort,
19-
getAgentPort,
2019
getDevConfig,
20+
getDevPort,
2121
getDevSupportedAgents,
2222
getEndpointUrl,
2323
invokeAgent,
@@ -27,6 +27,7 @@ import {
2727
loadDevEnv,
2828
loadProjectConfig,
2929
onShutdownSignal,
30+
requiresExactDevPort,
3031
} from '../../operations/dev';
3132
import { OtelCollector, startOtelCollector } from '../../operations/dev/otel';
3233
import { withCommandRunTelemetry } from '../../telemetry/cli-command-run.js';
@@ -265,7 +266,6 @@ export const registerDev = (program: Command) => {
265266
let invokePort = port;
266267
let targetAgent = invokeProject?.runtimes[0];
267268
if (opts.runtime && invokeProject) {
268-
invokePort = getAgentPort(invokeProject, opts.runtime, port, portExplicit);
269269
targetAgent = invokeProject.runtimes.find(a => a.name === opts.runtime);
270270
} else if (invokeProject && invokeProject.runtimes.length > 1 && !opts.runtime) {
271271
const names = invokeProject.runtimes.map(a => a.name).join(', ');
@@ -275,13 +275,13 @@ export const registerDev = (program: Command) => {
275275
}
276276

277277
const protocol = targetAgent?.protocol ?? 'HTTP';
278+
if (targetAgent && invokeProject) {
279+
invokePort = getDevPort(invokeProject, targetAgent.name, protocol, port, portExplicit);
280+
}
278281
recorder.set({
279282
agent_protocol: standardize(AgentProtocol, protocol.toLowerCase()),
280283
});
281284

282-
if (protocol === 'A2A') invokePort = 9000;
283-
else if (protocol === 'MCP') invokePort = 8000;
284-
285285
if (protocol === 'MCP') {
286286
await handleMcpInvoke(invokePort, invokePrompt, opts.tool, opts.input, headers);
287287
} else if (protocol === 'A2A') {
@@ -405,31 +405,25 @@ export const registerDev = (program: Command) => {
405405
agent_protocol: standardize(AgentProtocol, config.protocol.toLowerCase()),
406406
});
407407

408-
const isA2A = config.protocol === 'A2A';
409-
const isMcp = config.protocol === 'MCP';
410-
const isHttp = !isA2A && !isMcp;
411-
const fixedPort = isA2A
412-
? 9000
413-
: isMcp
414-
? 8000
415-
: getAgentPort(project, config.agentName, port, portExplicit);
416-
if (isHttp && !portExplicit && fixedPort !== port) {
408+
const requiresExactPort = requiresExactDevPort(config.protocol);
409+
const targetPort = getDevPort(project, config.agentName, config.protocol, port, portExplicit);
410+
if (config.protocol !== 'MCP' && !portExplicit && targetPort !== port) {
417411
const idx = project.runtimes.findIndex(a => a.name === config.agentName);
418412
console.log(
419-
`Runtime "${config.agentName}" is at index ${idx}; using port ${fixedPort} (pass --port ${fixedPort} to override).`
413+
`Runtime "${config.agentName}" is at index ${idx}; using port ${targetPort} (pass --port ${targetPort} to override).`
420414
);
421415
}
422-
const actualPort = await findAvailablePort(fixedPort);
423-
if ((isA2A || isMcp) && actualPort !== fixedPort) {
416+
const actualPort = await findAvailablePort(targetPort);
417+
if (requiresExactPort && actualPort !== targetPort) {
424418
throw new ValidationError(
425-
`Port ${fixedPort} is in use. ${config.protocol} agents require port ${fixedPort}.`
419+
`Port ${targetPort} is in use. ${config.protocol} agents require port ${targetPort}.`
426420
);
427421
}
428422
// An explicit -p must be honored literally; if it's taken, fail fast instead of
429423
// silently rebinding to a different port (the silent-shift behavior #1079 removes).
430-
if (isHttp && portExplicit && actualPort !== fixedPort) {
424+
if (!requiresExactPort && portExplicit && actualPort !== targetPort) {
431425
throw new ValidationError(
432-
`Port ${fixedPort} is in use. Free it or pass a different --port (no port is chosen automatically when --port is set explicitly).`
426+
`Port ${targetPort} is in use. Free it or pass a different --port (no port is chosen automatically when --port is set explicitly).`
433427
);
434428
}
435429

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

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

443-
if (actualPort !== fixedPort) {
444-
console.log(`Port ${fixedPort} in use, using ${actualPort}`);
437+
if (actualPort !== targetPort) {
438+
console.log(`Port ${targetPort} in use, using ${actualPort}`);
445439
}
446440

447441
console.log(`Starting dev server...`);

src/cli/operations/dev/__tests__/codezip-dev-server.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ const defaultOptions: DevServerOptions = { port: 8080, envVars: { MY_KEY: 'secre
3838

3939
describe('CodeZipDevServer spawn config', () => {
4040
beforeEach(() => {
41+
mockSpawn.mockClear();
4142
mockSpawn.mockReturnValue(createMockChildProcess());
4243
});
4344

@@ -106,7 +107,7 @@ describe('CodeZipDevServer spawn config', () => {
106107
);
107108
});
108109

109-
it('non-HTTP: passes env vars including PORT and LOCAL_DEV', async () => {
110+
it('A2A: passes the selected port and agent-card URL in the environment', async () => {
110111
const config: DevConfig = {
111112
agentName: 'A2aAgent',
112113
module: 'main.py',
@@ -123,6 +124,7 @@ describe('CodeZipDevServer spawn config', () => {
123124
const spawnCall = mockSpawn.mock.calls[0]!;
124125
const env = spawnCall[2].env;
125126
expect(env.PORT).toBe('8080');
127+
expect(env.AGENTCORE_RUNTIME_URL).toBe('http://localhost:8080/');
126128
expect(env.LOCAL_DEV).toBe('1');
127129
expect(env.MY_KEY).toBe('secret');
128130
});

src/cli/operations/dev/__tests__/config.test.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { AgentCoreProjectSpec, DirectoryPath, FilePath } from '../../../../schema';
2-
import { getAgentPort, getDevConfig, getDevSupportedAgents } from '../config';
2+
import { getAgentPort, getDevConfig, getDevPort, getDevSupportedAgents, requiresExactDevPort } from '../config';
33
import { describe, expect, it } from 'vitest';
44

55
// Helper to cast strings to branded path types for testing
@@ -671,6 +671,63 @@ describe('getAgentPort', () => {
671671
});
672672
});
673673

674+
describe('getDevPort', () => {
675+
const project: AgentCoreProjectSpec = {
676+
name: 'TestProject',
677+
version: 1,
678+
managedBy: 'CDK' as const,
679+
runtimes: [
680+
{
681+
name: 'AgentA',
682+
build: 'CodeZip',
683+
runtimeVersion: 'PYTHON_3_12',
684+
entrypoint: filePath('main.py'),
685+
codeLocation: dirPath('./agents/a'),
686+
protocol: 'A2A',
687+
},
688+
{
689+
name: 'AgentB',
690+
build: 'CodeZip',
691+
runtimeVersion: 'PYTHON_3_12',
692+
entrypoint: filePath('main.py'),
693+
codeLocation: dirPath('./agents/b'),
694+
protocol: 'A2A',
695+
},
696+
],
697+
memories: [],
698+
knowledgeBases: [],
699+
credentials: [],
700+
evaluators: [],
701+
onlineEvalConfigs: [],
702+
agentCoreGateways: [],
703+
policyEngines: [],
704+
configBundles: [],
705+
abTests: [],
706+
harnesses: [],
707+
datasets: [],
708+
payments: [],
709+
};
710+
711+
it('offsets the A2A default port by runtime index', () => {
712+
expect(getDevPort(project, 'AgentA', 'A2A', 8080)).toBe(9000);
713+
expect(getDevPort(project, 'AgentB', 'A2A', 8080)).toBe(9001);
714+
});
715+
716+
it('honors an explicit port for A2A', () => {
717+
expect(getDevPort(project, 'AgentB', 'A2A', 8788, true)).toBe(8788);
718+
});
719+
720+
it('keeps MCP on its fixed framework port', () => {
721+
expect(getDevPort(project, 'AgentB', 'MCP', 8788, true)).toBe(8000);
722+
});
723+
724+
it('requires A2A and MCP to bind their computed ports', () => {
725+
expect(requiresExactDevPort('A2A')).toBe(true);
726+
expect(requiresExactDevPort('MCP')).toBe(true);
727+
expect(requiresExactDevPort('HTTP')).toBe(false);
728+
});
729+
});
730+
674731
describe('getDevSupportedAgents', () => {
675732
it('returns empty array when project is null', () => {
676733
expect(getDevSupportedAgents(null)).toEqual([]);

src/cli/operations/dev/__tests__/container-dev-server.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,33 @@ describe('ContainerDevServer', () => {
343343
expect(spawnArgs).toContain(`9000:${CONTAINER_INTERNAL_PORT}`);
344344
});
345345

346+
it('maps a unique A2A host port to the A2A container port', async () => {
347+
mockSuccessfulPrepare();
348+
const config = { ...defaultConfig, protocol: 'A2A' as const };
349+
const options = { ...defaultOptions, port: 9001 };
350+
351+
const server = new ContainerDevServer(config, options);
352+
await server.start();
353+
354+
const spawnArgs = getSpawnArgs();
355+
expect(spawnArgs).toContain('9001:9000');
356+
expect(spawnArgs).toContain('PORT=9000');
357+
expect(spawnArgs).toContain('AGENTCORE_RUNTIME_URL=http://localhost:9001/');
358+
});
359+
360+
it('maps an MCP host port to the MCP container port', async () => {
361+
mockSuccessfulPrepare();
362+
const config = { ...defaultConfig, protocol: 'MCP' as const };
363+
const options = { ...defaultOptions, port: 8000 };
364+
365+
const server = new ContainerDevServer(config, options);
366+
await server.start();
367+
368+
const spawnArgs = getSpawnArgs();
369+
expect(spawnArgs).toContain('8000:8000');
370+
expect(spawnArgs).toContain('PORT=8000');
371+
});
372+
346373
it('includes user-provided environment variables', async () => {
347374
mockSuccessfulPrepare();
348375

src/cli/operations/dev/codezip-dev-server.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,9 @@ export class CodeZipDevServer extends DevServer {
145145
if (protocol === 'MCP') {
146146
env.FASTMCP_PORT = String(port);
147147
}
148+
if (protocol === 'A2A') {
149+
env.AGENTCORE_RUNTIME_URL = `http://localhost:${port}/`;
150+
}
148151

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

0 commit comments

Comments
 (0)