From caa3f291f88fe2ab1ee75a3d0b42285e382270cf Mon Sep 17 00:00:00 2001 From: Avi Alpert Date: Wed, 20 May 2026 15:13:11 -0400 Subject: [PATCH 1/7] feat: add invoke functionality to web-ui --- .../commands/invoke/__tests__/resolve.test.ts | 479 ++++++++++++++++++ src/cli/commands/invoke/action.ts | 151 +----- src/cli/commands/invoke/index.ts | 2 + src/cli/commands/invoke/resolve.ts | 164 ++++++ .../dev/web-ui/handlers/invocations.ts | 253 ++++++++- src/cli/operations/dev/web-ui/web-server.ts | 2 +- 6 files changed, 926 insertions(+), 125 deletions(-) create mode 100644 src/cli/commands/invoke/__tests__/resolve.test.ts create mode 100644 src/cli/commands/invoke/resolve.ts diff --git a/src/cli/commands/invoke/__tests__/resolve.test.ts b/src/cli/commands/invoke/__tests__/resolve.test.ts new file mode 100644 index 000000000..02c2f5359 --- /dev/null +++ b/src/cli/commands/invoke/__tests__/resolve.test.ts @@ -0,0 +1,479 @@ +import { ResourceNotFoundError, ValidationError } from '../../../../lib'; +import type { AgentCoreProjectSpec, AwsDeploymentTargets, DeployedState } from '../../../../schema'; +import { canFetchRuntimeToken, fetchRuntimeToken } from '../../../operations/fetch-access'; +import { resolveInvokeTarget } from '../resolve'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../operations/fetch-access', () => ({ + canFetchRuntimeToken: vi.fn(), + fetchRuntimeToken: vi.fn(), +})); + +vi.mock('../../../operations/session', () => ({ + generateSessionId: vi.fn(() => 'generated-session-id'), +})); + +const mockedCanFetch = vi.mocked(canFetchRuntimeToken); +const mockedFetchToken = vi.mocked(fetchRuntimeToken); + +function makeProject(overrides: Partial = {}): AgentCoreProjectSpec { + return { + name: 'test-project', + runtimes: [ + { + name: 'my-agent', + build: 'CodeZip', + codeLocation: './agents/my-agent', + entrypoint: 'main.py', + runtimeVersion: '1.0', + networkMode: 'PUBLIC', + }, + ], + credentials: [], + ...overrides, + } as AgentCoreProjectSpec; +} + +function makeDeployedState(overrides: Partial = {}): DeployedState { + return { + targets: { + default: { + resources: { + runtimes: { + 'my-agent': { + runtimeId: 'rt-123', + runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789:runtime/rt-123', + roleArn: 'arn:aws:iam::123456789:role/test-role', + }, + }, + }, + }, + }, + ...overrides, + } as DeployedState; +} + +function makeAwsTargets(): AwsDeploymentTargets { + return [{ name: 'default', account: '123456789', region: 'us-east-1' }]; +} + +describe('resolveInvokeTarget', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('resolves successfully with default target and single agent', async () => { + const result = await resolveInvokeTarget({ + project: makeProject(), + deployedState: makeDeployedState(), + awsTargets: makeAwsTargets(), + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.agentSpec.name).toBe('my-agent'); + expect(result.targetName).toBe('default'); + expect(result.region).toBe('us-east-1'); + expect(result.runtimeArn).toBe('arn:aws:bedrock-agentcore:us-east-1:123456789:runtime/rt-123'); + }); + + it('returns error when no deployed targets exist', async () => { + const result = await resolveInvokeTarget({ + project: makeProject(), + deployedState: { targets: {} } as DeployedState, + awsTargets: makeAwsTargets(), + }); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toBeInstanceOf(ResourceNotFoundError); + expect(result.error.message).toContain('No deployed targets found'); + }); + + it('returns error when specified target name does not exist', async () => { + const result = await resolveInvokeTarget({ + project: makeProject(), + deployedState: makeDeployedState(), + awsTargets: makeAwsTargets(), + targetName: 'nonexistent', + }); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toBeInstanceOf(ResourceNotFoundError); + expect(result.error.message).toContain("'nonexistent' not found"); + }); + + it('returns error when target config is missing from aws-targets', async () => { + const result = await resolveInvokeTarget({ + project: makeProject(), + deployedState: makeDeployedState(), + awsTargets: [], + }); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toBeInstanceOf(ResourceNotFoundError); + expect(result.error.message).toContain("Target config 'default' not found"); + }); + + it('returns error when no runtimes are defined', async () => { + const result = await resolveInvokeTarget({ + project: makeProject({ runtimes: [] }), + deployedState: makeDeployedState(), + awsTargets: makeAwsTargets(), + }); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toBeInstanceOf(ValidationError); + expect(result.error.message).toContain('No agents defined'); + }); + + it('returns error when multiple runtimes exist but no agentName specified', async () => { + const project = makeProject({ + runtimes: [ + { + name: 'agent-a', + build: 'CodeZip', + codeLocation: '.', + entrypoint: 'a.py', + runtimeVersion: '1.0', + networkMode: 'PUBLIC', + }, + { + name: 'agent-b', + build: 'CodeZip', + codeLocation: '.', + entrypoint: 'b.py', + runtimeVersion: '1.0', + networkMode: 'PUBLIC', + }, + ] as unknown as AgentCoreProjectSpec['runtimes'], + }); + + const result = await resolveInvokeTarget({ + project, + deployedState: makeDeployedState(), + awsTargets: makeAwsTargets(), + }); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toBeInstanceOf(ValidationError); + expect(result.error.message).toContain('Multiple runtimes found'); + }); + + it('returns error when specified agent name does not exist', async () => { + const result = await resolveInvokeTarget({ + project: makeProject(), + deployedState: makeDeployedState(), + awsTargets: makeAwsTargets(), + agentName: 'nonexistent', + }); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toBeInstanceOf(ResourceNotFoundError); + expect(result.error.message).toContain("'nonexistent' not found"); + }); + + it('returns error when agent is not deployed to the target', async () => { + const deployedState = makeDeployedState({ + targets: { + default: { + resources: { + runtimes: {}, + }, + }, + }, + } as unknown as DeployedState); + + const result = await resolveInvokeTarget({ + project: makeProject(), + deployedState, + awsTargets: makeAwsTargets(), + }); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toBeInstanceOf(ValidationError); + expect(result.error.message).toContain("'my-agent' is not deployed"); + }); + + it('resolves specific agent by name', async () => { + const project = makeProject({ + runtimes: [ + { + name: 'agent-a', + build: 'CodeZip', + codeLocation: '.', + entrypoint: 'a.py', + runtimeVersion: '1.0', + networkMode: 'PUBLIC', + }, + { + name: 'agent-b', + build: 'CodeZip', + codeLocation: '.', + entrypoint: 'b.py', + runtimeVersion: '1.0', + networkMode: 'PUBLIC', + }, + ] as unknown as AgentCoreProjectSpec['runtimes'], + }); + + const deployedState = { + targets: { + default: { + resources: { + runtimes: { + 'agent-a': { + runtimeId: 'rt-a', + runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-a', + roleArn: 'arn:aws:iam::123:role/r', + }, + 'agent-b': { + runtimeId: 'rt-b', + runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-b', + roleArn: 'arn:aws:iam::123:role/r', + }, + }, + }, + }, + }, + } as unknown as DeployedState; + + const result = await resolveInvokeTarget({ + project, + deployedState, + awsTargets: makeAwsTargets(), + agentName: 'agent-b', + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.agentSpec.name).toBe('agent-b'); + expect(result.runtimeArn).toContain('rt-b'); + }); + + describe('CUSTOM_JWT token resolution', () => { + function makeJwtProject(): AgentCoreProjectSpec { + return makeProject({ + runtimes: [ + { + name: 'my-agent', + build: 'CodeZip', + codeLocation: '.', + entrypoint: 'main.py', + runtimeVersion: '1.0', + networkMode: 'PUBLIC', + authorizerType: 'CUSTOM_JWT', + authorizerConfiguration: { + customJwtAuthorizer: { issuerUrl: 'https://issuer.example.com', audiences: ['aud'] }, + }, + }, + ] as unknown as AgentCoreProjectSpec['runtimes'], + }); + } + + it('auto-fetches bearer token for CUSTOM_JWT agents', async () => { + mockedCanFetch.mockResolvedValue(true); + mockedFetchToken.mockResolvedValue({ token: 'jwt-token-123', expiresIn: 3600 }); + + const result = await resolveInvokeTarget({ + project: makeJwtProject(), + deployedState: makeDeployedState(), + awsTargets: makeAwsTargets(), + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.bearerToken).toBe('jwt-token-123'); + expect(mockedCanFetch).toHaveBeenCalledWith('my-agent', undefined); + expect(mockedFetchToken).toHaveBeenCalledWith('my-agent', { deployTarget: 'default' }); + }); + + it('skips token fetch when bearerToken is already provided', async () => { + const result = await resolveInvokeTarget({ + project: makeJwtProject(), + deployedState: makeDeployedState(), + awsTargets: makeAwsTargets(), + bearerToken: 'pre-existing-token', + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.bearerToken).toBe('pre-existing-token'); + expect(mockedCanFetch).not.toHaveBeenCalled(); + }); + + it('returns error when canFetchRuntimeToken is false', async () => { + mockedCanFetch.mockResolvedValue(false); + + const result = await resolveInvokeTarget({ + project: makeJwtProject(), + deployedState: makeDeployedState(), + awsTargets: makeAwsTargets(), + }); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toBeInstanceOf(ValidationError); + expect(result.error.message).toContain('no bearer token is available'); + }); + + it('returns error when fetchRuntimeToken throws', async () => { + mockedCanFetch.mockResolvedValue(true); + mockedFetchToken.mockRejectedValue(new Error('token endpoint unreachable')); + + const result = await resolveInvokeTarget({ + project: makeJwtProject(), + deployedState: makeDeployedState(), + awsTargets: makeAwsTargets(), + }); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toBeInstanceOf(ValidationError); + expect(result.error.message).toContain('Auto-fetch failed'); + expect(result.error.message).toContain('token endpoint unreachable'); + }); + }); + + describe('session ID generation', () => { + it('generates session ID when bearer token is present and no session ID provided', async () => { + const result = await resolveInvokeTarget({ + project: makeProject(), + deployedState: makeDeployedState(), + awsTargets: makeAwsTargets(), + bearerToken: 'some-token', + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.sessionId).toBe('generated-session-id'); + }); + + it('preserves provided session ID even with bearer token', async () => { + const result = await resolveInvokeTarget({ + project: makeProject(), + deployedState: makeDeployedState(), + awsTargets: makeAwsTargets(), + bearerToken: 'some-token', + sessionId: 'my-session', + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.sessionId).toBe('my-session'); + }); + + it('does not generate session ID when no bearer token', async () => { + const result = await resolveInvokeTarget({ + project: makeProject(), + deployedState: makeDeployedState(), + awsTargets: makeAwsTargets(), + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.sessionId).toBeUndefined(); + }); + }); + + describe('config bundle baggage', () => { + it('constructs baggage when a config bundle is associated with the agent', async () => { + const project = makeProject({ + configBundles: [ + { + name: 'my-bundle', + components: { '{{runtime:my-agent}}': { type: 'inference-profile' } }, + }, + ], + } as unknown as Partial); + + const deployedState = { + targets: { + default: { + resources: { + runtimes: { + 'my-agent': { + runtimeId: 'rt-123', + runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789:runtime/rt-123', + roleArn: 'arn:aws:iam::123456789:role/test-role', + }, + }, + configBundles: { + 'my-bundle': { + bundleArn: 'arn:aws:bedrock-agentcore:us-east-1:123:config-bundle/cb-1', + versionId: 'v2', + }, + }, + }, + }, + }, + } as unknown as DeployedState; + + const result = await resolveInvokeTarget({ + project, + deployedState, + awsTargets: makeAwsTargets(), + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.baggage).toContain('aws.agentcore.configbundle_arn='); + expect(result.baggage).toContain('aws.agentcore.configbundle_version='); + expect(result.baggage).toContain( + encodeURIComponent('arn:aws:bedrock-agentcore:us-east-1:123:config-bundle/cb-1') + ); + expect(result.baggage).toContain(encodeURIComponent('v2')); + }); + + it('returns no baggage when no config bundle is associated', async () => { + const result = await resolveInvokeTarget({ + project: makeProject(), + deployedState: makeDeployedState(), + awsTargets: makeAwsTargets(), + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.baggage).toBeUndefined(); + }); + }); + + it('passes configIO to token fetch functions when provided', async () => { + const project = makeProject({ + runtimes: [ + { + name: 'my-agent', + build: 'CodeZip', + codeLocation: '.', + entrypoint: 'main.py', + runtimeVersion: '1.0', + networkMode: 'PUBLIC', + authorizerType: 'CUSTOM_JWT', + authorizerConfiguration: { + customJwtAuthorizer: { issuerUrl: 'https://issuer.example.com', audiences: ['aud'] }, + }, + }, + ] as unknown as AgentCoreProjectSpec['runtimes'], + }); + + mockedCanFetch.mockResolvedValue(true); + mockedFetchToken.mockResolvedValue({ token: 'tok', expiresIn: 3600 }); + + const fakeConfigIO = {} as any; + await resolveInvokeTarget({ + project, + deployedState: makeDeployedState(), + awsTargets: makeAwsTargets(), + configIO: fakeConfigIO, + }); + + expect(mockedCanFetch).toHaveBeenCalledWith('my-agent', { configIO: fakeConfigIO }); + expect(mockedFetchToken).toHaveBeenCalledWith('my-agent', { configIO: fakeConfigIO, deployTarget: 'default' }); + }); +}); diff --git a/src/cli/commands/invoke/action.ts b/src/cli/commands/invoke/action.ts index ad21c7113..70523565f 100644 --- a/src/cli/commands/invoke/action.ts +++ b/src/cli/commands/invoke/action.ts @@ -1,4 +1,4 @@ -import { ConfigIO, ResourceNotFoundError, ValidationError } from '../../../lib'; +import { ConfigIO, ValidationError } from '../../../lib'; import type { AgentCoreProjectSpec, AwsDeploymentTargets, DeployedState } from '../../../schema'; import { buildAguiRunInput, @@ -13,8 +13,7 @@ import { } from '../../aws'; import { InvokeLogger } from '../../logging'; import { formatMcpToolList } from '../../operations/dev/utils'; -import { canFetchRuntimeToken, fetchRuntimeToken } from '../../operations/fetch-access'; -import { generateSessionId } from '../../operations/session'; +import { resolveInvokeTarget } from './resolve'; import type { InvokeOptions, InvokeResult } from './types'; export interface InvokeContext { @@ -40,62 +39,26 @@ export async function loadInvokeConfig(configIO: ConfigIO = new ConfigIO()): Pro export async function handleInvoke(context: InvokeContext, options: InvokeOptions = {}): Promise { const { project, deployedState, awsTargets } = context; - // Resolve target - const targetNames = Object.keys(deployedState.targets); - if (targetNames.length === 0) { - return { - success: false, - error: new ResourceNotFoundError('No deployed targets found. Run `agentcore deploy` first.'), - }; - } - - const selectedTargetName = options.targetName ?? targetNames[0]!; - - if (options.targetName && !targetNames.includes(options.targetName)) { - return { - success: false, - error: new ResourceNotFoundError( - `Target '${options.targetName}' not found. Available: ${targetNames.join(', ')}` - ), - }; - } - - const targetState = deployedState.targets[selectedTargetName]; - const targetConfig = awsTargets.find(t => t.name === selectedTargetName); - - if (!targetConfig) { - return { - success: false, - error: new ResourceNotFoundError(`Target config '${selectedTargetName}' not found in aws-targets`), - }; - } - - if (project.runtimes.length === 0) { - return { success: false, error: new ValidationError('No agents defined in configuration') }; - } - - // Resolve agent - const agentNames = project.runtimes.map(a => a.name); - - if (!options.agentName && project.runtimes.length > 1) { - return { - success: false, - error: new ValidationError(`Multiple runtimes found. Use --runtime to specify one: ${agentNames.join(', ')}`), - }; - } - - const agentSpec = options.agentName ? project.runtimes.find(a => a.name === options.agentName) : project.runtimes[0]; + const resolved = await resolveInvokeTarget({ + project, + deployedState, + awsTargets, + agentName: options.agentName, + targetName: options.targetName, + bearerToken: options.bearerToken, + sessionId: options.sessionId, + }); - if (options.agentName && !agentSpec) { - return { - success: false, - error: new ResourceNotFoundError(`Agent '${options.agentName}' not found. Available: ${agentNames.join(', ')}`), - }; + if (!resolved.success) { + return { success: false, error: resolved.error }; } - if (!agentSpec) { - return { success: false, error: new ValidationError('No agents defined in configuration') }; - } + const { agentSpec, targetName: selectedTargetName, targetConfig, runtimeArn, baggage } = resolved; + options = { + ...options, + bearerToken: resolved.bearerToken ?? options.bearerToken, + sessionId: resolved.sessionId ?? options.sessionId, + }; // Warn about VPC mode endpoint requirements if (agentSpec.networkMode === 'VPC') { @@ -104,69 +67,11 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption ); } - // Get the deployed state for this specific agent - const agentState = targetState?.resources?.runtimes?.[agentSpec.name]; - - if (!agentState) { - return { - success: false, - error: new ValidationError(`Agent '${agentSpec.name}' is not deployed to target '${selectedTargetName}'`), - }; - } - - // Build config bundle baggage if a bundle is associated with this agent - const deployedBundles = targetState?.resources?.configBundles ?? {}; - let baggage: string | undefined; - const bundleSpec = project.configBundles?.find(b => { - const keys = Object.keys(b.components ?? {}); - return keys.some(k => k === `{{runtime:${agentSpec.name}}}`); - }); - if (bundleSpec) { - const bundleState = deployedBundles[bundleSpec.name]; - if (bundleState?.bundleArn && bundleState?.versionId) { - baggage = `aws.agentcore.configbundle_arn=${encodeURIComponent(bundleState.bundleArn)},aws.agentcore.configbundle_version=${encodeURIComponent(bundleState.versionId)}`; - } - } - - // Auto-fetch bearer token for CUSTOM_JWT agents when not provided - if (agentSpec.authorizerType === 'CUSTOM_JWT' && !options.bearerToken) { - const canFetch = await canFetchRuntimeToken(agentSpec.name); - if (canFetch) { - try { - const tokenResult = await fetchRuntimeToken(agentSpec.name, { deployTarget: selectedTargetName }); - options = { ...options, bearerToken: tokenResult.token }; - } catch (err) { - return { - success: false, - error: new ValidationError( - `CUSTOM_JWT agent requires a bearer token. Auto-fetch failed: ${err instanceof Error ? err.message : String(err)}\nProvide one manually with --bearer-token.`, - { cause: err } - ), - }; - } - } else { - return { - success: false, - error: new ValidationError( - `Agent '${agentSpec.name}' is configured for CUSTOM_JWT but no bearer token is available.\nEither provide --bearer-token or re-add the agent with --client-id and --client-secret to enable auto-fetch.` - ), - }; - } - } - - // When invoking with a bearer token (OAuth/CUSTOM_JWT), AgentCore does not - // auto-generate a runtime session ID the way it does for SigV4 callers. Templates - // that wire up AgentCoreMemorySessionManager require a non-null session_id, so - // generate one here if the caller didn't pass --session-id. - if (options.bearerToken && !options.sessionId) { - options = { ...options, sessionId: generateSessionId() }; - } - // Exec mode: run shell command in runtime container if (options.exec) { const logger = new InvokeLogger({ agentName: agentSpec.name, - runtimeArn: agentState.runtimeArn, + runtimeArn: runtimeArn, region: targetConfig.region, sessionId: options.sessionId, }); @@ -179,7 +84,7 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption try { const result = await executeBashCommand({ region: targetConfig.region, - runtimeArn: agentState.runtimeArn, + runtimeArn: runtimeArn, command, sessionId: options.sessionId, timeout: options.timeout, @@ -276,7 +181,7 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption if (agentSpec.protocol === 'MCP') { const mcpOpts = { region: targetConfig.region, - runtimeArn: agentState.runtimeArn, + runtimeArn: runtimeArn, userId: options.userId, headers: options.headers, bearerToken: options.bearerToken, @@ -360,7 +265,7 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption const a2aResult = await invokeA2ARuntime( { region: targetConfig.region, - runtimeArn: agentState.runtimeArn, + runtimeArn: runtimeArn, userId: options.userId, sessionId: options.sessionId, headers: options.headers, @@ -395,7 +300,7 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption if (agentSpec.protocol === 'AGUI') { const logger = new InvokeLogger({ agentName: agentSpec.name, - runtimeArn: agentState.runtimeArn, + runtimeArn: runtimeArn, region: targetConfig.region, }); @@ -406,7 +311,7 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption const aguiResult = await invokeAguiRuntime( { region: targetConfig.region, - runtimeArn: agentState.runtimeArn, + runtimeArn: runtimeArn, sessionId: options.sessionId, userId: options.userId, logger, @@ -464,7 +369,7 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption // Create logger for this invocation const logger = new InvokeLogger({ agentName: agentSpec.name, - runtimeArn: agentState.runtimeArn, + runtimeArn: runtimeArn, region: targetConfig.region, sessionId: options.sessionId, }); @@ -477,7 +382,7 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption try { const result = await invokeAgentRuntimeStreaming({ region: targetConfig.region, - runtimeArn: agentState.runtimeArn, + runtimeArn: runtimeArn, payload: options.prompt, sessionId: options.sessionId, userId: options.userId, @@ -512,7 +417,7 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption // Non-streaming mode const response = await invokeAgentRuntime({ region: targetConfig.region, - runtimeArn: agentState.runtimeArn, + runtimeArn: runtimeArn, payload: options.prompt, sessionId: options.sessionId, userId: options.userId, diff --git a/src/cli/commands/invoke/index.ts b/src/cli/commands/invoke/index.ts index a4b4bd48e..9847bd3f0 100644 --- a/src/cli/commands/invoke/index.ts +++ b/src/cli/commands/invoke/index.ts @@ -1,4 +1,6 @@ export { registerInvoke } from './command'; export { handleInvoke, loadInvokeConfig } from './action'; export type { InvokeContext } from './action'; +export { resolveInvokeTarget } from './resolve'; +export type { ResolveInvokeInput, ResolvedInvokeTarget, ResolveInvokeResult } from './resolve'; export type { InvokeResult, InvokeOptions } from './types'; diff --git a/src/cli/commands/invoke/resolve.ts b/src/cli/commands/invoke/resolve.ts new file mode 100644 index 000000000..4b76c4a6d --- /dev/null +++ b/src/cli/commands/invoke/resolve.ts @@ -0,0 +1,164 @@ +import { ResourceNotFoundError, ValidationError } from '../../../lib'; +import type { ConfigIO } from '../../../lib'; +import type { + AgentCoreProjectSpec, + AgentEnvSpec, + AwsDeploymentTarget, + AwsDeploymentTargets, + DeployedState, +} from '../../../schema'; +import { canFetchRuntimeToken, fetchRuntimeToken } from '../../operations/fetch-access'; +import { generateSessionId } from '../../operations/session'; + +export interface ResolveInvokeInput { + project: AgentCoreProjectSpec; + deployedState: DeployedState; + awsTargets: AwsDeploymentTargets; + agentName?: string; + targetName?: string; + bearerToken?: string; + sessionId?: string; + configIO?: ConfigIO; +} + +export interface ResolvedInvokeTarget { + agentSpec: AgentEnvSpec; + targetName: string; + targetConfig: AwsDeploymentTarget; + region: string; + runtimeArn: string; + bearerToken?: string; + sessionId?: string; + baggage?: string; +} + +export type ResolveInvokeResult = ({ success: true } & ResolvedInvokeTarget) | { success: false; error: Error }; + +export async function resolveInvokeTarget(input: ResolveInvokeInput): Promise { + const { project, deployedState, awsTargets } = input; + + const targetNames = Object.keys(deployedState.targets); + if (targetNames.length === 0) { + return { + success: false, + error: new ResourceNotFoundError('No deployed targets found. Run `agentcore deploy` first.'), + }; + } + + const selectedTargetName = input.targetName ?? targetNames[0]!; + + if (input.targetName && !targetNames.includes(input.targetName)) { + return { + success: false, + error: new ResourceNotFoundError(`Target '${input.targetName}' not found. Available: ${targetNames.join(', ')}`), + }; + } + + const targetState = deployedState.targets[selectedTargetName]; + const targetConfig = awsTargets.find(t => t.name === selectedTargetName); + + if (!targetConfig) { + return { + success: false, + error: new ResourceNotFoundError(`Target config '${selectedTargetName}' not found in aws-targets`), + }; + } + + if (project.runtimes.length === 0) { + return { success: false, error: new ValidationError('No agents defined in configuration') }; + } + + const agentNames = project.runtimes.map(a => a.name); + + if (!input.agentName && project.runtimes.length > 1) { + return { + success: false, + error: new ValidationError(`Multiple runtimes found. Use --runtime to specify one: ${agentNames.join(', ')}`), + }; + } + + const agentSpec = input.agentName ? project.runtimes.find(a => a.name === input.agentName) : project.runtimes[0]; + + if (input.agentName && !agentSpec) { + return { + success: false, + error: new ResourceNotFoundError(`Agent '${input.agentName}' not found. Available: ${agentNames.join(', ')}`), + }; + } + + if (!agentSpec) { + return { success: false, error: new ValidationError('No agents defined in configuration') }; + } + + const agentState = targetState?.resources?.runtimes?.[agentSpec.name]; + + if (!agentState) { + return { + success: false, + error: new ValidationError(`Agent '${agentSpec.name}' is not deployed to target '${selectedTargetName}'`), + }; + } + + // Build config bundle baggage if a bundle is associated with this agent + const deployedBundles = targetState?.resources?.configBundles ?? {}; + let baggage: string | undefined; + const bundleSpec = project.configBundles?.find(b => { + const keys = Object.keys(b.components ?? {}); + return keys.some(k => k === `{{runtime:${agentSpec.name}}}`); + }); + if (bundleSpec) { + const bundleState = deployedBundles[bundleSpec.name]; + if (bundleState?.bundleArn && bundleState?.versionId) { + baggage = `aws.agentcore.configbundle_arn=${encodeURIComponent(bundleState.bundleArn)},aws.agentcore.configbundle_version=${encodeURIComponent(bundleState.versionId)}`; + } + } + + // Auto-fetch bearer token for CUSTOM_JWT agents when not provided + let bearerToken = input.bearerToken; + if (agentSpec.authorizerType === 'CUSTOM_JWT' && !bearerToken) { + const fetchOpts = input.configIO ? { configIO: input.configIO } : undefined; + const canFetch = await canFetchRuntimeToken(agentSpec.name, fetchOpts); + if (canFetch) { + try { + const tokenResult = await fetchRuntimeToken(agentSpec.name, { + ...fetchOpts, + deployTarget: selectedTargetName, + }); + bearerToken = tokenResult.token; + } catch (err) { + return { + success: false, + error: new ValidationError( + `CUSTOM_JWT agent requires a bearer token. Auto-fetch failed: ${err instanceof Error ? err.message : String(err)}\nProvide one manually with --bearer-token.`, + { cause: err } + ), + }; + } + } else { + return { + success: false, + error: new ValidationError( + `Agent '${agentSpec.name}' is configured for CUSTOM_JWT but no bearer token is available.\nEither provide --bearer-token or re-add the agent with --client-id and --client-secret to enable auto-fetch.` + ), + }; + } + } + + // When invoking with a bearer token, generate a session ID if not provided + let sessionId = input.sessionId; + if (bearerToken && !sessionId) { + sessionId = generateSessionId(); + } + + return { + success: true, + agentSpec, + targetName: selectedTargetName, + targetConfig, + region: targetConfig.region, + runtimeArn: agentState.runtimeArn, + bearerToken, + sessionId, + baggage, + }; +} diff --git a/src/cli/operations/dev/web-ui/handlers/invocations.ts b/src/cli/operations/dev/web-ui/handlers/invocations.ts index 3a6b70ed9..da6335101 100644 --- a/src/cli/operations/dev/web-ui/handlers/invocations.ts +++ b/src/cli/operations/dev/web-ui/handlers/invocations.ts @@ -1,5 +1,9 @@ +import { ConfigIO } from '../../../../../lib'; +import { invokeA2ARuntime, invokeAgentRuntimeStreaming, invokeAguiRuntime } from '../../../../aws/agentcore'; +import { buildAguiRunInput } from '../../../../aws/agui-types'; +import { resolveInvokeTarget } from '../../../../commands/invoke/resolve'; import { extractSSEEventText, extractTaskText, isStatusUpdateEvent } from '../../invoke-a2a'; -import type { RouteContext } from './route-context'; +import { type RouteContext, parseRequestUrl } from './route-context'; import { randomUUID } from 'node:crypto'; import { type IncomingMessage, type ServerResponse, request as httpRequest } from 'node:http'; @@ -8,6 +12,7 @@ let a2aRequestId = 1; /** * POST /invocations — proxy to the selected agent. * Body must include agentName to route to the correct running agent. + * When ?target=deployed is set, invokes the deployed runtime via the AWS SDK. */ export async function handleInvocations( ctx: RouteContext, @@ -15,6 +20,11 @@ export async function handleInvocations( res: ServerResponse, origin?: string ): Promise { + const { param } = parseRequestUrl(req); + if (param('target') === 'deployed') { + return handleDeployedInvocation(ctx, req, res, origin); + } + const body = await ctx.readBody(req); let agentPort: number | undefined; @@ -331,3 +341,244 @@ async function handleAguiInvocation( proxyReq.end(); }); } + +/** + * Invoke a deployed agent runtime via the AWS SDK. + * Reuses the same SSE response format as local invocations so the frontend + * can parse both paths identically. + */ +async function handleDeployedInvocation( + ctx: RouteContext, + req: IncomingMessage, + res: ServerResponse, + origin?: string +): Promise { + const { configRoot } = ctx.options; + if (!configRoot) { + ctx.setCorsHeaders(res, origin); + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'No agentcore project found' })); + return; + } + + const rawBody = await ctx.readBody(req); + let agentName: string | undefined; + let prompt: string | undefined; + let sessionId: string | undefined; + let userId: string | undefined; + try { + const parsed = JSON.parse(rawBody) as { + agentName?: string; + prompt?: string; + sessionId?: string; + userId?: string; + }; + agentName = parsed.agentName; + prompt = parsed.prompt; + sessionId = parsed.sessionId; + userId = parsed.userId; + } catch { + ctx.setCorsHeaders(res, origin); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'Invalid JSON body' })); + return; + } + + if (!prompt) { + ctx.setCorsHeaders(res, origin); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'prompt is required' })); + return; + } + + const configIO = new ConfigIO({ baseDir: configRoot }); + let project; + let deployedState; + let awsTargets; + try { + project = await configIO.readProjectSpec(); + deployedState = await configIO.readDeployedState(); + awsTargets = await configIO.readAWSDeploymentTargets(); + } catch (err) { + ctx.setCorsHeaders(res, origin); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + success: false, + error: `Failed to load config: ${err instanceof Error ? err.message : String(err)}`, + }) + ); + return; + } + + const resolved = await resolveInvokeTarget({ + project, + deployedState, + awsTargets, + agentName, + sessionId, + configIO, + }); + + if (!resolved.success) { + ctx.setCorsHeaders(res, origin); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: resolved.error.message })); + return; + } + + sessionId ??= resolved.sessionId ?? randomUUID(); + + try { + const protocol = resolved.agentSpec.protocol ?? 'HTTP'; + + if (protocol === 'A2A') { + await handleDeployedA2AInvocation(ctx, res, origin, { + region: resolved.region, + runtimeArn: resolved.runtimeArn, + prompt, + sessionId, + userId, + }); + } else if (protocol === 'AGUI') { + await handleDeployedAguiInvocation(ctx, res, origin, { + region: resolved.region, + runtimeArn: resolved.runtimeArn, + prompt, + sessionId, + userId, + bearerToken: resolved.bearerToken, + }); + } else { + await handleDeployedHttpInvocation(ctx, res, origin, { + region: resolved.region, + runtimeArn: resolved.runtimeArn, + prompt, + sessionId, + userId, + bearerToken: resolved.bearerToken, + }); + } + } catch (err) { + if (!res.headersSent) { + ctx.setCorsHeaders(res, origin); + res.writeHead(502, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + success: false, + error: `Invoke failed: ${err instanceof Error ? err.message : String(err)}`, + }) + ); + } + } +} + +interface DeployedInvokeParams { + region: string; + runtimeArn: string; + prompt: string; + sessionId?: string; + userId?: string; + bearerToken?: string; +} + +async function handleDeployedHttpInvocation( + ctx: RouteContext, + res: ServerResponse, + origin: string | undefined, + params: DeployedInvokeParams +): Promise { + const result = await invokeAgentRuntimeStreaming({ + region: params.region, + runtimeArn: params.runtimeArn, + payload: params.prompt, + sessionId: params.sessionId, + userId: params.userId, + bearerToken: params.bearerToken, + }); + + ctx.setCorsHeaders(res, origin); + const headers: Record = { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }; + if (result.sessionId) { + headers['x-session-id'] = result.sessionId; + } + res.writeHead(200, headers); + + for await (const chunk of result.stream) { + res.write(`data: ${JSON.stringify(chunk)}\n\n`); + } + res.end(); +} + +async function handleDeployedA2AInvocation( + ctx: RouteContext, + res: ServerResponse, + origin: string | undefined, + params: DeployedInvokeParams +): Promise { + const result = await invokeA2ARuntime( + { + region: params.region, + runtimeArn: params.runtimeArn, + userId: params.userId, + sessionId: params.sessionId, + }, + params.prompt + ); + + ctx.setCorsHeaders(res, origin); + const headers: Record = { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }; + if (result.sessionId) { + headers['x-session-id'] = result.sessionId; + } + res.writeHead(200, headers); + + for await (const chunk of result.stream) { + res.write(`data: ${JSON.stringify(chunk)}\n\n`); + } + res.end(); +} + +async function handleDeployedAguiInvocation( + ctx: RouteContext, + res: ServerResponse, + origin: string | undefined, + params: DeployedInvokeParams +): Promise { + const input = buildAguiRunInput(params.prompt, params.sessionId); + const result = await invokeAguiRuntime( + { + region: params.region, + runtimeArn: params.runtimeArn, + sessionId: params.sessionId, + userId: params.userId, + bearerToken: params.bearerToken, + }, + input + ); + + ctx.setCorsHeaders(res, origin); + const headers: Record = { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }; + if (result.sessionId) { + headers['x-session-id'] = result.sessionId; + } + res.writeHead(200, headers); + + // Pipe raw AGUI events through — frontend parses them directly + for await (const chunk of result.textStream) { + res.write(`data: ${JSON.stringify(chunk)}\n\n`); + } + res.end(); +} diff --git a/src/cli/operations/dev/web-ui/web-server.ts b/src/cli/operations/dev/web-ui/web-server.ts index fe845194f..f5332715a 100644 --- a/src/cli/operations/dev/web-ui/web-server.ts +++ b/src/cli/operations/dev/web-ui/web-server.ts @@ -340,7 +340,7 @@ export class WebUIServer { await handleListCloudWatchTraces(ctx, req, res, origin); } else if (req.method === 'POST' && req.url === '/api/start') { await handleStart(ctx, req, res, origin); - } else if (req.method === 'POST' && req.url === '/invocations') { + } else if (req.method === 'POST' && (req.url === '/invocations' || req.url?.startsWith('/invocations?'))) { await handleInvocations(ctx, req, res, origin); } else if (req.method === 'POST' && req.url === '/api/mcp') { await handleMcpProxy(ctx, req, res, origin); From b33fe351365f1cdde4679dd03009fb00f1f5a193 Mon Sep 17 00:00:00 2001 From: Avi Alpert Date: Thu, 21 May 2026 13:05:20 -0400 Subject: [PATCH 2/7] fix: respond to comments --- .../__tests__/deployed-invocations.test.ts | 312 ++++++++++++++++++ src/cli/operations/dev/web-ui/api-types.ts | 9 + .../dev/web-ui/handlers/invocations.ts | 38 ++- .../dev/web-ui/handlers/resources.ts | 8 + 4 files changed, 357 insertions(+), 10 deletions(-) create mode 100644 src/cli/operations/dev/web-ui/__tests__/deployed-invocations.test.ts diff --git a/src/cli/operations/dev/web-ui/__tests__/deployed-invocations.test.ts b/src/cli/operations/dev/web-ui/__tests__/deployed-invocations.test.ts new file mode 100644 index 000000000..0c5d2be73 --- /dev/null +++ b/src/cli/operations/dev/web-ui/__tests__/deployed-invocations.test.ts @@ -0,0 +1,312 @@ +import { ConfigIO } from '../../../../../lib'; +import { invokeA2ARuntime, invokeAgentRuntimeStreaming, invokeAguiRuntime } from '../../../../aws/agentcore'; +import { resolveInvokeTarget } from '../../../../commands/invoke/resolve'; +import { handleInvocations } from '../handlers/invocations.js'; +import type { RouteContext } from '../handlers/route-context.js'; +import type { IncomingMessage, ServerResponse } from 'http'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../../commands/invoke/resolve', () => ({ + resolveInvokeTarget: vi.fn(), +})); + +vi.mock('../../../../aws/agentcore', () => ({ + invokeAgentRuntimeStreaming: vi.fn(), + invokeA2ARuntime: vi.fn(), + invokeAguiRuntime: vi.fn(), +})); + +vi.mock('../../../../aws/agui-types', () => ({ + buildAguiRunInput: vi.fn((_prompt: string, _session?: string) => ({ prompt: _prompt })), +})); + +vi.mock('../../../../../lib', () => { + const MockConfigIO = vi.fn(); + MockConfigIO.prototype.readProjectSpec = vi.fn(); + MockConfigIO.prototype.readDeployedState = vi.fn(); + MockConfigIO.prototype.readAWSDeploymentTargets = vi.fn(); + return { ConfigIO: MockConfigIO }; +}); + +const mockedResolve = vi.mocked(resolveInvokeTarget); +const mockedInvokeStreaming = vi.mocked(invokeAgentRuntimeStreaming); +const mockedInvokeA2A = vi.mocked(invokeA2ARuntime); +const mockedInvokeAgui = vi.mocked(invokeAguiRuntime); + +interface MockRes extends ServerResponse { + _status: number; + _headers: Record; + _body: string; + _chunks: string[]; +} + +function mockRes(): MockRes { + const res = { + _status: 0, + _headers: {} as Record, + _body: '', + _chunks: [] as string[], + headersSent: false, + writeHead(status: number, headers?: Record) { + res._status = status; + res.headersSent = true; + if (headers) Object.assign(res._headers, headers); + return res; + }, + setHeader(name: string, value: string) { + res._headers[name] = value; + }, + write(chunk: string) { + res._chunks.push(chunk); + return true; + }, + end(body?: string) { + if (body) res._body = body; + }, + }; + return res as unknown as MockRes; +} + +function mockReq(url: string): IncomingMessage { + return { url, headers: { host: 'localhost:8081' } } as unknown as IncomingMessage; +} + +function mockCtx(overrides: Partial = {}, bodyValue?: string): RouteContext { + return { + options: { + mode: 'dev', + agents: [], + harnesses: [], + uiPort: 8081, + configRoot: '/tmp/test-project/agentcore', + ...overrides, + }, + runningAgents: new Map(), + startingAgents: new Map(), + agentErrors: new Map(), + setCorsHeaders: vi.fn(), + readBody: vi.fn().mockResolvedValue(bodyValue ?? ''), + } as RouteContext; +} + +async function* streamChunks(chunks: unknown[]): AsyncGenerator { + for (const chunk of chunks) { + await Promise.resolve(); + yield chunk; + } +} + +async function* failingStream(chunks: unknown[], error: Error): AsyncGenerator { + for (const chunk of chunks) { + await Promise.resolve(); + yield chunk; + } + throw error; +} + +function mockConfigIO(overrides: Partial<{ project: unknown; state: unknown; targets: unknown }> = {}) { + const proto = ConfigIO.prototype as any; + proto.readProjectSpec.mockResolvedValue(overrides.project ?? {}); + proto.readDeployedState.mockResolvedValue(overrides.state ?? { targets: {} }); + proto.readAWSDeploymentTargets.mockResolvedValue(overrides.targets ?? []); +} + +describe('handleInvocations ?target=deployed', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockConfigIO(); + }); + + it('returns 404 when no configRoot', async () => { + const ctx = mockCtx({ configRoot: undefined }, JSON.stringify({ prompt: 'hello' })); + const req = mockReq('/invocations?target=deployed'); + const res = mockRes(); + + await handleInvocations(ctx, req, res); + + expect(res._status).toBe(404); + expect(JSON.parse(res._body).error).toContain('No agentcore project found'); + }); + + it('returns 400 for invalid JSON body', async () => { + const ctx = mockCtx({}, 'not json'); + const req = mockReq('/invocations?target=deployed'); + const res = mockRes(); + + await handleInvocations(ctx, req, res); + + expect(res._status).toBe(400); + expect(JSON.parse(res._body).error).toContain('Invalid JSON body'); + }); + + it('returns 400 when prompt is missing', async () => { + const ctx = mockCtx({}, JSON.stringify({ agentName: 'my-agent' })); + const req = mockReq('/invocations?target=deployed'); + const res = mockRes(); + + await handleInvocations(ctx, req, res); + + expect(res._status).toBe(400); + expect(JSON.parse(res._body).error).toContain('prompt is required'); + }); + + it('returns 400 when resolve fails', async () => { + mockedResolve.mockResolvedValue({ + success: false, + error: new Error('Agent not deployed'), + }); + + const ctx = mockCtx({}, JSON.stringify({ prompt: 'hello', agentName: 'missing' })); + const req = mockReq('/invocations?target=deployed'); + const res = mockRes(); + + await handleInvocations(ctx, req, res); + + expect(res._status).toBe(400); + expect(JSON.parse(res._body).error).toBe('Agent not deployed'); + }); + + it('passes targetName to resolveInvokeTarget', async () => { + mockedResolve.mockResolvedValue({ + success: false, + error: new Error('not found'), + }); + + const ctx = mockCtx({}, JSON.stringify({ prompt: 'hi', agentName: 'a', targetName: 'prod' })); + const req = mockReq('/invocations?target=deployed'); + const res = mockRes(); + + await handleInvocations(ctx, req, res); + + expect(mockedResolve).toHaveBeenCalledWith(expect.objectContaining({ targetName: 'prod' })); + }); + + it('invokes HTTP streaming for default protocol', async () => { + mockedResolve.mockResolvedValue({ + success: true, + agentSpec: { name: 'my-agent', protocol: 'HTTP' } as any, + targetName: 'default', + targetConfig: { name: 'default', region: 'us-east-1', account: '123' } as any, + region: 'us-east-1', + runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-1', + sessionId: 'sess-1', + }); + + mockedInvokeStreaming.mockResolvedValue({ + stream: streamChunks([{ text: 'Hello' }, { text: ' world' }]), + sessionId: 'sess-1', + } as any); + + const ctx = mockCtx({}, JSON.stringify({ prompt: 'hi' })); + const req = mockReq('/invocations?target=deployed'); + const res = mockRes(); + + await handleInvocations(ctx, req, res); + + expect(res._status).toBe(200); + expect(res._headers['Content-Type']).toBe('text/event-stream'); + expect(res._chunks).toHaveLength(2); + expect(res._chunks[0]).toContain('"text":"Hello"'); + expect(res._chunks[1]).toContain('"text":" world"'); + }); + + it('invokes A2A for A2A protocol agents', async () => { + mockedResolve.mockResolvedValue({ + success: true, + agentSpec: { name: 'a2a-agent', protocol: 'A2A' } as any, + targetName: 'default', + targetConfig: { name: 'default', region: 'us-west-2', account: '123' } as any, + region: 'us-west-2', + runtimeArn: 'arn:aws:bedrock-agentcore:us-west-2:123:runtime/rt-2', + sessionId: 'sess-2', + }); + + mockedInvokeA2A.mockResolvedValue({ + stream: streamChunks([{ type: 'message', content: 'done' }]), + sessionId: 'sess-2', + } as any); + + const ctx = mockCtx({}, JSON.stringify({ prompt: 'run task' })); + const req = mockReq('/invocations?target=deployed'); + const res = mockRes(); + + await handleInvocations(ctx, req, res); + + expect(mockedInvokeA2A).toHaveBeenCalledWith( + expect.objectContaining({ region: 'us-west-2', runtimeArn: expect.stringContaining('rt-2') }), + 'run task' + ); + expect(res._status).toBe(200); + expect(res._chunks).toHaveLength(1); + }); + + it('invokes AGUI for AGUI protocol agents', async () => { + mockedResolve.mockResolvedValue({ + success: true, + agentSpec: { name: 'agui-agent', protocol: 'AGUI' } as any, + targetName: 'default', + targetConfig: { name: 'default', region: 'us-east-1', account: '123' } as any, + region: 'us-east-1', + runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-3', + sessionId: 'sess-3', + bearerToken: 'tok-123', + }); + + mockedInvokeAgui.mockResolvedValue({ + textStream: streamChunks([{ event: 'text', data: 'hi' }]), + sessionId: 'sess-3', + } as any); + + const ctx = mockCtx({}, JSON.stringify({ prompt: 'do thing' })); + const req = mockReq('/invocations?target=deployed'); + const res = mockRes(); + + await handleInvocations(ctx, req, res); + + expect(mockedInvokeAgui).toHaveBeenCalled(); + expect(res._status).toBe(200); + expect(res._chunks).toHaveLength(1); + }); + + it('emits SSE error frame and ends response on mid-stream failure', async () => { + mockedResolve.mockResolvedValue({ + success: true, + agentSpec: { name: 'my-agent', protocol: 'HTTP' } as any, + targetName: 'default', + targetConfig: { name: 'default', region: 'us-east-1', account: '123' } as any, + region: 'us-east-1', + runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-1', + sessionId: 'sess-1', + }); + + mockedInvokeStreaming.mockResolvedValue({ + stream: failingStream([{ text: 'partial' }], new Error('connection reset')), + sessionId: 'sess-1', + } as any); + + const ctx = mockCtx({}, JSON.stringify({ prompt: 'hi' })); + const req = mockReq('/invocations?target=deployed'); + const res = mockRes(); + + await handleInvocations(ctx, req, res); + + expect(res._status).toBe(200); + expect(res._chunks[0]).toContain('"text":"partial"'); + const errorChunk = res._chunks.find(c => c.includes('"error"')); + expect(errorChunk).toBeDefined(); + expect(errorChunk).toContain('connection reset'); + }); + + it('returns 500 when config loading fails', async () => { + (ConfigIO.prototype as any).readProjectSpec.mockRejectedValue(new Error('file not found')); + + const ctx = mockCtx({}, JSON.stringify({ prompt: 'hi' })); + const req = mockReq('/invocations?target=deployed'); + const res = mockRes(); + + await handleInvocations(ctx, req, res); + + expect(res._status).toBe(500); + expect(JSON.parse(res._body).error).toContain('Failed to load config'); + }); +}); diff --git a/src/cli/operations/dev/web-ui/api-types.ts b/src/cli/operations/dev/web-ui/api-types.ts index 5d4cc2d41..1633ae713 100644 --- a/src/cli/operations/dev/web-ui/api-types.ts +++ b/src/cli/operations/dev/web-ui/api-types.ts @@ -116,6 +116,14 @@ export interface ResourcesResponse { onlineEvalConfigs: ResourceOnlineEvalConfig[]; policyEngines: ResourcePolicyEngine[]; unassignedTargets: ResourceUnassignedTarget[]; + deploymentTargets: ResourceDeploymentTarget[]; +} + +/** Deployment target (from aws-targets) in the resources response */ +export interface ResourceDeploymentTarget { + name: string; + region: string; + description?: string; } /** Agent details in the resources response */ @@ -252,6 +260,7 @@ export interface StartResponse { /** Request body for POST /invocations */ export interface InvocationRequest { agentName?: string; + targetName?: string; prompt?: string; sessionId?: string; userId?: string; diff --git a/src/cli/operations/dev/web-ui/handlers/invocations.ts b/src/cli/operations/dev/web-ui/handlers/invocations.ts index da6335101..732f2f88e 100644 --- a/src/cli/operations/dev/web-ui/handlers/invocations.ts +++ b/src/cli/operations/dev/web-ui/handlers/invocations.ts @@ -363,17 +363,20 @@ async function handleDeployedInvocation( const rawBody = await ctx.readBody(req); let agentName: string | undefined; + let targetName: string | undefined; let prompt: string | undefined; let sessionId: string | undefined; let userId: string | undefined; try { const parsed = JSON.parse(rawBody) as { agentName?: string; + targetName?: string; prompt?: string; sessionId?: string; userId?: string; }; agentName = parsed.agentName; + targetName = parsed.targetName; prompt = parsed.prompt; sessionId = parsed.sessionId; userId = parsed.userId; @@ -416,6 +419,7 @@ async function handleDeployedInvocation( deployedState, awsTargets, agentName, + targetName, sessionId, configIO, }); @@ -508,10 +512,15 @@ async function handleDeployedHttpInvocation( } res.writeHead(200, headers); - for await (const chunk of result.stream) { - res.write(`data: ${JSON.stringify(chunk)}\n\n`); + try { + for await (const chunk of result.stream) { + res.write(`data: ${JSON.stringify(chunk)}\n\n`); + } + } catch (err) { + res.write(`data: ${JSON.stringify({ error: err instanceof Error ? err.message : String(err) })}\n\n`); + } finally { + res.end(); } - res.end(); } async function handleDeployedA2AInvocation( @@ -541,10 +550,15 @@ async function handleDeployedA2AInvocation( } res.writeHead(200, headers); - for await (const chunk of result.stream) { - res.write(`data: ${JSON.stringify(chunk)}\n\n`); + try { + for await (const chunk of result.stream) { + res.write(`data: ${JSON.stringify(chunk)}\n\n`); + } + } catch (err) { + res.write(`data: ${JSON.stringify({ error: err instanceof Error ? err.message : String(err) })}\n\n`); + } finally { + res.end(); } - res.end(); } async function handleDeployedAguiInvocation( @@ -576,9 +590,13 @@ async function handleDeployedAguiInvocation( } res.writeHead(200, headers); - // Pipe raw AGUI events through — frontend parses them directly - for await (const chunk of result.textStream) { - res.write(`data: ${JSON.stringify(chunk)}\n\n`); + try { + for await (const chunk of result.textStream) { + res.write(`data: ${JSON.stringify(chunk)}\n\n`); + } + } catch (err) { + res.write(`data: ${JSON.stringify({ error: err instanceof Error ? err.message : String(err) })}\n\n`); + } finally { + res.end(); } - res.end(); } diff --git a/src/cli/operations/dev/web-ui/handlers/resources.ts b/src/cli/operations/dev/web-ui/handlers/resources.ts index 47c4e00ef..96af40a33 100644 --- a/src/cli/operations/dev/web-ui/handlers/resources.ts +++ b/src/cli/operations/dev/web-ui/handlers/resources.ts @@ -6,6 +6,7 @@ import type { ResourceAgent, ResourceCredential, ResourceDeploymentStatus, + ResourceDeploymentTarget, ResourceEvaluator, ResourceGateway, ResourceMemory, @@ -47,10 +48,16 @@ export async function handleResources(ctx: RouteContext, res: ServerResponse, or // Read AWS targets to resolve region for invocation URLs. let targetRegion: string | undefined; + let deploymentTargets: ResourceDeploymentTarget[] = []; try { const awsTargets = await configIO.readAWSDeploymentTargets(); const firstTarget = firstTargetKey ? awsTargets.find(t => t.name === firstTargetKey) : awsTargets[0]; targetRegion = firstTarget?.region; + deploymentTargets = awsTargets.map(t => ({ + name: t.name, + region: t.region, + ...(t.description ? { description: t.description } : {}), + })); } catch { // aws-targets.json may not exist yet — region will be undefined } @@ -282,6 +289,7 @@ export async function handleResources(ctx: RouteContext, res: ServerResponse, or onlineEvalConfigs, policyEngines, unassignedTargets, + deploymentTargets, }) ); } catch (err) { From a6c10cf5c59975f8714ac7cf2a0196bc55ba5800 Mon Sep 17 00:00:00 2001 From: Avi Alpert Date: Thu, 21 May 2026 13:49:07 -0400 Subject: [PATCH 3/7] fix: support mcp --- .../dev/web-ui/__tests__/mcp-proxy.test.ts | 2 +- .../dev/web-ui/handlers/mcp-proxy.ts | 142 +++++++++++++++++- src/cli/operations/dev/web-ui/web-server.ts | 2 +- 3 files changed, 143 insertions(+), 3 deletions(-) diff --git a/src/cli/operations/dev/web-ui/__tests__/mcp-proxy.test.ts b/src/cli/operations/dev/web-ui/__tests__/mcp-proxy.test.ts index c2dcfc615..5e56af071 100644 --- a/src/cli/operations/dev/web-ui/__tests__/mcp-proxy.test.ts +++ b/src/cli/operations/dev/web-ui/__tests__/mcp-proxy.test.ts @@ -4,7 +4,7 @@ import type { IncomingMessage, ServerResponse } from 'http'; import { beforeEach, describe, expect, it, vi } from 'vitest'; function mockReq(_body: string): IncomingMessage { - return {} as IncomingMessage; + return { url: '/api/mcp', headers: { host: 'localhost:8081' } } as unknown as IncomingMessage; } function mockRes(): ServerResponse & { _status: number; _headers: Record; _body: string } { diff --git a/src/cli/operations/dev/web-ui/handlers/mcp-proxy.ts b/src/cli/operations/dev/web-ui/handlers/mcp-proxy.ts index bfbdb1b87..9d50928bc 100644 --- a/src/cli/operations/dev/web-ui/handlers/mcp-proxy.ts +++ b/src/cli/operations/dev/web-ui/handlers/mcp-proxy.ts @@ -1,4 +1,7 @@ -import type { RouteContext } from './route-context.js'; +import { ConfigIO } from '../../../../../lib'; +import { mcpCallTool, mcpInitSession, mcpListTools } from '../../../../aws/agentcore'; +import { resolveInvokeTarget } from '../../../../commands/invoke/resolve'; +import { type RouteContext, parseRequestUrl } from './route-context.js'; import type { IncomingMessage, ServerResponse } from 'http'; export async function handleMcpProxy( @@ -7,6 +10,11 @@ export async function handleMcpProxy( res: ServerResponse, origin?: string ): Promise { + const { param } = parseRequestUrl(req); + if (param('target') === 'deployed') { + return handleDeployedMcpProxy(ctx, req, res, origin); + } + ctx.setCorsHeaders(res, origin); const raw = await ctx.readBody(req); @@ -80,3 +88,135 @@ export async function handleMcpProxy( res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: true, result, sessionId: responseSessionId })); } + +async function handleDeployedMcpProxy( + ctx: RouteContext, + req: IncomingMessage, + res: ServerResponse, + origin?: string +): Promise { + const { configRoot } = ctx.options; + if (!configRoot) { + ctx.setCorsHeaders(res, origin); + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'No agentcore project found' })); + return; + } + + const raw = await ctx.readBody(req); + let parsed: { + agentName?: string; + targetName?: string; + body?: Record; + sessionId?: string; + }; + try { + parsed = JSON.parse(raw) as typeof parsed; + } catch { + ctx.setCorsHeaders(res, origin); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'Invalid JSON' })); + return; + } + + const { agentName, targetName, body, sessionId } = parsed; + + if (!body) { + ctx.setCorsHeaders(res, origin); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'body is required' })); + return; + } + + const configIO = new ConfigIO({ baseDir: configRoot }); + let project; + let deployedState; + let awsTargets; + try { + project = await configIO.readProjectSpec(); + deployedState = await configIO.readDeployedState(); + awsTargets = await configIO.readAWSDeploymentTargets(); + } catch (err) { + ctx.setCorsHeaders(res, origin); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + success: false, + error: `Failed to load config: ${err instanceof Error ? err.message : String(err)}`, + }) + ); + return; + } + + const resolved = await resolveInvokeTarget({ + project, + deployedState, + awsTargets, + agentName, + targetName, + sessionId, + configIO, + }); + + if (!resolved.success) { + ctx.setCorsHeaders(res, origin); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: resolved.error.message })); + return; + } + + const mcpOpts = { + region: resolved.region, + runtimeArn: resolved.runtimeArn, + bearerToken: resolved.bearerToken, + mcpSessionId: sessionId, + }; + + const method = (body as { method?: string }).method; + + try { + if (method === 'initialize') { + const mcpSessionId = await mcpInitSession(mcpOpts); + ctx.setCorsHeaders(res, origin); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true, result: { jsonrpc: '2.0', result: {} }, sessionId: mcpSessionId })); + } else if (method === 'tools/list') { + const result = await mcpListTools(mcpOpts); + ctx.setCorsHeaders(res, origin); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true, result: { jsonrpc: '2.0', result } })); + } else if (method === 'tools/call') { + const params = (body as { params?: { name?: string; arguments?: Record } }).params; + if (!params?.name) { + ctx.setCorsHeaders(res, origin); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'tools/call requires params.name' })); + return; + } + const response = await mcpCallTool(mcpOpts, params.name, params.arguments ?? {}); + ctx.setCorsHeaders(res, origin); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + success: true, + result: { jsonrpc: '2.0', result: { content: [{ type: 'text', text: response }] } }, + }) + ); + } else { + ctx.setCorsHeaders(res, origin); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: `Unsupported MCP method: ${method}` })); + } + } catch (err) { + ctx.setCorsHeaders(res, origin); + if (!res.headersSent) { + res.writeHead(502, { 'Content-Type': 'application/json' }); + } + res.end( + JSON.stringify({ + success: false, + error: `MCP invoke failed: ${err instanceof Error ? err.message : String(err)}`, + }) + ); + } +} diff --git a/src/cli/operations/dev/web-ui/web-server.ts b/src/cli/operations/dev/web-ui/web-server.ts index f5332715a..b8b00c3e0 100644 --- a/src/cli/operations/dev/web-ui/web-server.ts +++ b/src/cli/operations/dev/web-ui/web-server.ts @@ -342,7 +342,7 @@ export class WebUIServer { await handleStart(ctx, req, res, origin); } else if (req.method === 'POST' && (req.url === '/invocations' || req.url?.startsWith('/invocations?'))) { await handleInvocations(ctx, req, res, origin); - } else if (req.method === 'POST' && req.url === '/api/mcp') { + } else if (req.method === 'POST' && (req.url === '/api/mcp' || req.url?.startsWith('/api/mcp?'))) { await handleMcpProxy(ctx, req, res, origin); } else if (req.method === 'GET' && req.url?.startsWith('/api/a2a/agent-card')) { await handleA2AAgentCard(ctx, req, res, origin); From b82c89f7a7a14e08c3cb4b5d06748d382267fa29 Mon Sep 17 00:00:00 2001 From: Avi Alpert Date: Thu, 21 May 2026 16:38:30 -0400 Subject: [PATCH 4/7] fix: tests --- .../dev/web-ui/__tests__/mcp-proxy.test.ts | 331 +++++++++++++++++- 1 file changed, 322 insertions(+), 9 deletions(-) diff --git a/src/cli/operations/dev/web-ui/__tests__/mcp-proxy.test.ts b/src/cli/operations/dev/web-ui/__tests__/mcp-proxy.test.ts index 5e56af071..c325d1be8 100644 --- a/src/cli/operations/dev/web-ui/__tests__/mcp-proxy.test.ts +++ b/src/cli/operations/dev/web-ui/__tests__/mcp-proxy.test.ts @@ -1,10 +1,36 @@ +import { ConfigIO } from '../../../../../lib'; +import { mcpCallTool, mcpInitSession, mcpListTools } from '../../../../aws/agentcore'; +import { resolveInvokeTarget } from '../../../../commands/invoke/resolve'; import { handleMcpProxy } from '../handlers/mcp-proxy.js'; import type { RouteContext } from '../handlers/route-context.js'; import type { IncomingMessage, ServerResponse } from 'http'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -function mockReq(_body: string): IncomingMessage { - return { url: '/api/mcp', headers: { host: 'localhost:8081' } } as unknown as IncomingMessage; +vi.mock('../../../../commands/invoke/resolve', () => ({ + resolveInvokeTarget: vi.fn(), +})); + +vi.mock('../../../../aws/agentcore', () => ({ + mcpInitSession: vi.fn(), + mcpListTools: vi.fn(), + mcpCallTool: vi.fn(), +})); + +vi.mock('../../../../../lib', () => { + const MockConfigIO = vi.fn(); + MockConfigIO.prototype.readProjectSpec = vi.fn(); + MockConfigIO.prototype.readDeployedState = vi.fn(); + MockConfigIO.prototype.readAWSDeploymentTargets = vi.fn(); + return { ConfigIO: MockConfigIO }; +}); + +const mockedResolve = vi.mocked(resolveInvokeTarget); +const mockedMcpInitSession = vi.mocked(mcpInitSession); +const mockedMcpListTools = vi.mocked(mcpListTools); +const mockedMcpCallTool = vi.mocked(mcpCallTool); + +function mockReq(url: string): IncomingMessage { + return { url, headers: { host: 'localhost:8081' } } as unknown as IncomingMessage; } function mockRes(): ServerResponse & { _status: number; _headers: Record; _body: string } { @@ -29,7 +55,7 @@ function mockRes(): ServerResponse & { _status: number; _headers: Record = {}): RouteContext { return { - options: { mode: 'dev' } as RouteContext['options'], + options: { mode: 'dev', configRoot: '/tmp/test-project/agentcore' } as RouteContext['options'], runningAgents: new Map(), startingAgents: new Map(), agentErrors: new Map(), @@ -39,6 +65,31 @@ function mockCtx(overrides: Partial = {}): RouteContext { } as unknown as RouteContext; } +function mockDeployedCtx(overrides: Partial = {}, bodyValue?: string): RouteContext { + return { + options: { + mode: 'dev', + agents: [], + harnesses: [], + uiPort: 8081, + configRoot: '/tmp/test-project/agentcore', + ...overrides, + }, + runningAgents: new Map(), + startingAgents: new Map(), + agentErrors: new Map(), + setCorsHeaders: vi.fn(), + readBody: vi.fn().mockResolvedValue(bodyValue ?? ''), + } as RouteContext; +} + +function mockConfigIO(overrides: Partial<{ project: unknown; state: unknown; targets: unknown }> = {}) { + const proto = ConfigIO.prototype as any; + proto.readProjectSpec.mockResolvedValue(overrides.project ?? {}); + proto.readDeployedState.mockResolvedValue(overrides.state ?? { targets: {} }); + proto.readAWSDeploymentTargets.mockResolvedValue(overrides.targets ?? []); +} + describe('handleMcpProxy', () => { beforeEach(() => { vi.restoreAllMocks(); @@ -46,7 +97,7 @@ describe('handleMcpProxy', () => { it('returns 400 when agentName is missing', async () => { const ctx = mockCtx({ readBody: vi.fn().mockResolvedValue(JSON.stringify({ body: {} })) }); - const req = mockReq(''); + const req = mockReq('/api/mcp'); const res = mockRes(); await handleMcpProxy(ctx, req, res, undefined); @@ -57,7 +108,7 @@ describe('handleMcpProxy', () => { it('returns 400 when body is missing', async () => { const ctx = mockCtx({ readBody: vi.fn().mockResolvedValue(JSON.stringify({ agentName: 'test-agent' })) }); - const req = mockReq(''); + const req = mockReq('/api/mcp'); const res = mockRes(); await handleMcpProxy(ctx, req, res, undefined); @@ -70,7 +121,7 @@ describe('handleMcpProxy', () => { const ctx = mockCtx({ readBody: vi.fn().mockResolvedValue(JSON.stringify({ agentName: 'test-agent', body: { jsonrpc: '2.0' } })), }); - const req = mockReq(''); + const req = mockReq('/api/mcp'); const res = mockRes(); await handleMcpProxy(ctx, req, res, undefined); @@ -86,7 +137,7 @@ describe('handleMcpProxy', () => { runningAgents: agents, readBody: vi.fn().mockResolvedValue(JSON.stringify({ agentName: 'test-agent', body: jsonRpcBody })), }); - const req = mockReq(''); + const req = mockReq('/api/mcp'); const res = mockRes(); const mcpResponse = { jsonrpc: '2.0', id: 1, result: { tools: [] } }; @@ -123,7 +174,7 @@ describe('handleMcpProxy', () => { JSON.stringify({ agentName: 'test-agent', body: jsonRpcBody, sessionId: 'existing-session' }) ), }); - const req = mockReq(''); + const req = mockReq('/api/mcp'); const res = mockRes(); vi.stubGlobal( @@ -153,7 +204,7 @@ describe('handleMcpProxy', () => { JSON.stringify({ agentName: 'test-agent', body: { jsonrpc: '2.0', id: 1, method: 'tools/list' } }) ), }); - const req = mockReq(''); + const req = mockReq('/api/mcp'); const res = mockRes(); vi.stubGlobal( @@ -173,3 +224,265 @@ describe('handleMcpProxy', () => { vi.unstubAllGlobals(); }); }); + +describe('handleMcpProxy ?target=deployed', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockConfigIO(); + }); + + it('returns 404 when no configRoot', async () => { + const ctx = mockDeployedCtx({ configRoot: undefined }, JSON.stringify({ body: { method: 'initialize' } })); + const req = mockReq('/api/mcp?target=deployed'); + const res = mockRes(); + + await handleMcpProxy(ctx, req, res, undefined); + + expect(res._status).toBe(404); + expect(JSON.parse(res._body)).toEqual({ success: false, error: 'No agentcore project found' }); + }); + + it('returns 400 on invalid JSON body', async () => { + const ctx = mockDeployedCtx({}, 'not json'); + const req = mockReq('/api/mcp?target=deployed'); + const res = mockRes(); + + await handleMcpProxy(ctx, req, res, undefined); + + expect(res._status).toBe(400); + expect(JSON.parse(res._body)).toEqual({ success: false, error: 'Invalid JSON' }); + }); + + it('returns 400 when body field is missing', async () => { + const ctx = mockDeployedCtx({}, JSON.stringify({ agentName: 'my-agent' })); + const req = mockReq('/api/mcp?target=deployed'); + const res = mockRes(); + + await handleMcpProxy(ctx, req, res, undefined); + + expect(res._status).toBe(400); + expect(JSON.parse(res._body)).toEqual({ success: false, error: 'body is required' }); + }); + + it('returns 400 when resolveInvokeTarget fails', async () => { + mockedResolve.mockResolvedValue({ + success: false, + error: new Error('Agent not deployed'), + }); + + const ctx = mockDeployedCtx({}, JSON.stringify({ agentName: 'missing', body: { method: 'initialize' } })); + const req = mockReq('/api/mcp?target=deployed'); + const res = mockRes(); + + await handleMcpProxy(ctx, req, res, undefined); + + expect(res._status).toBe(400); + expect(JSON.parse(res._body)).toEqual({ success: false, error: 'Agent not deployed' }); + }); + + it('returns 500 when config loading throws', async () => { + (ConfigIO.prototype as any).readProjectSpec.mockRejectedValue(new Error('file not found')); + + const ctx = mockDeployedCtx({}, JSON.stringify({ body: { method: 'initialize' } })); + const req = mockReq('/api/mcp?target=deployed'); + const res = mockRes(); + + await handleMcpProxy(ctx, req, res, undefined); + + expect(res._status).toBe(500); + expect(JSON.parse(res._body).error).toContain('Failed to load config'); + expect(JSON.parse(res._body).error).toContain('file not found'); + }); + + it('handles initialize method and returns sessionId', async () => { + mockedResolve.mockResolvedValue({ + success: true, + agentSpec: { name: 'mcp-agent', protocol: 'MCP' } as any, + targetName: 'default', + targetConfig: { name: 'default', region: 'us-east-1', account: '123' } as any, + region: 'us-east-1', + runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-1', + bearerToken: 'tok-1', + }); + mockedMcpInitSession.mockResolvedValue('mcp-session-abc'); + + const ctx = mockDeployedCtx({}, JSON.stringify({ agentName: 'mcp-agent', body: { method: 'initialize' } })); + const req = mockReq('/api/mcp?target=deployed'); + const res = mockRes(); + + await handleMcpProxy(ctx, req, res, undefined); + + expect(res._status).toBe(200); + const parsed = JSON.parse(res._body); + expect(parsed).toEqual({ + success: true, + result: { jsonrpc: '2.0', result: {} }, + sessionId: 'mcp-session-abc', + }); + }); + + it('handles tools/list method', async () => { + mockedResolve.mockResolvedValue({ + success: true, + agentSpec: { name: 'mcp-agent', protocol: 'MCP' } as any, + targetName: 'default', + targetConfig: { name: 'default', region: 'us-east-1', account: '123' } as any, + region: 'us-east-1', + runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-1', + bearerToken: 'tok-1', + }); + const toolsResult = { tools: [{ name: 'get_weather', description: 'Get weather' }] }; + mockedMcpListTools.mockResolvedValue(toolsResult as any); + + const ctx = mockDeployedCtx( + {}, + JSON.stringify({ agentName: 'mcp-agent', body: { method: 'tools/list' }, sessionId: 'sess-1' }) + ); + const req = mockReq('/api/mcp?target=deployed'); + const res = mockRes(); + + await handleMcpProxy(ctx, req, res, undefined); + + expect(res._status).toBe(200); + const parsed = JSON.parse(res._body); + expect(parsed).toEqual({ + success: true, + result: { jsonrpc: '2.0', result: toolsResult }, + }); + expect(mockedMcpListTools).toHaveBeenCalledWith( + expect.objectContaining({ + region: 'us-east-1', + runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-1', + bearerToken: 'tok-1', + mcpSessionId: 'sess-1', + }) + ); + }); + + it('handles tools/call method', async () => { + mockedResolve.mockResolvedValue({ + success: true, + agentSpec: { name: 'mcp-agent', protocol: 'MCP' } as any, + targetName: 'default', + targetConfig: { name: 'default', region: 'us-east-1', account: '123' } as any, + region: 'us-east-1', + runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-1', + bearerToken: 'tok-1', + }); + mockedMcpCallTool.mockResolvedValue('sunny, 72F'); + + const ctx = mockDeployedCtx( + {}, + JSON.stringify({ + agentName: 'mcp-agent', + body: { method: 'tools/call', params: { name: 'get_weather', arguments: { city: 'NYC' } } }, + sessionId: 'sess-1', + }) + ); + const req = mockReq('/api/mcp?target=deployed'); + const res = mockRes(); + + await handleMcpProxy(ctx, req, res, undefined); + + expect(res._status).toBe(200); + const parsed = JSON.parse(res._body); + expect(parsed).toEqual({ + success: true, + result: { jsonrpc: '2.0', result: { content: [{ type: 'text', text: 'sunny, 72F' }] } }, + }); + expect(mockedMcpCallTool).toHaveBeenCalledWith(expect.objectContaining({ mcpSessionId: 'sess-1' }), 'get_weather', { + city: 'NYC', + }); + }); + + it('returns 400 when tools/call is missing params.name', async () => { + mockedResolve.mockResolvedValue({ + success: true, + agentSpec: { name: 'mcp-agent', protocol: 'MCP' } as any, + targetName: 'default', + targetConfig: { name: 'default', region: 'us-east-1', account: '123' } as any, + region: 'us-east-1', + runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-1', + bearerToken: 'tok-1', + }); + + const ctx = mockDeployedCtx( + {}, + JSON.stringify({ + agentName: 'mcp-agent', + body: { method: 'tools/call', params: {} }, + }) + ); + const req = mockReq('/api/mcp?target=deployed'); + const res = mockRes(); + + await handleMcpProxy(ctx, req, res, undefined); + + expect(res._status).toBe(400); + expect(JSON.parse(res._body)).toEqual({ success: false, error: 'tools/call requires params.name' }); + }); + + it('returns 400 for unsupported method', async () => { + mockedResolve.mockResolvedValue({ + success: true, + agentSpec: { name: 'mcp-agent', protocol: 'MCP' } as any, + targetName: 'default', + targetConfig: { name: 'default', region: 'us-east-1', account: '123' } as any, + region: 'us-east-1', + runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-1', + bearerToken: 'tok-1', + }); + + const ctx = mockDeployedCtx({}, JSON.stringify({ agentName: 'mcp-agent', body: { method: 'resources/list' } })); + const req = mockReq('/api/mcp?target=deployed'); + const res = mockRes(); + + await handleMcpProxy(ctx, req, res, undefined); + + expect(res._status).toBe(400); + expect(JSON.parse(res._body)).toEqual({ success: false, error: 'Unsupported MCP method: resources/list' }); + }); + + it('returns 502 when AWS SDK call throws', async () => { + mockedResolve.mockResolvedValue({ + success: true, + agentSpec: { name: 'mcp-agent', protocol: 'MCP' } as any, + targetName: 'default', + targetConfig: { name: 'default', region: 'us-east-1', account: '123' } as any, + region: 'us-east-1', + runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-1', + bearerToken: 'tok-1', + }); + mockedMcpListTools.mockRejectedValue(new Error('ThrottlingException')); + + const ctx = mockDeployedCtx({}, JSON.stringify({ agentName: 'mcp-agent', body: { method: 'tools/list' } })); + const req = mockReq('/api/mcp?target=deployed'); + const res = mockRes(); + + await handleMcpProxy(ctx, req, res, undefined); + + expect(res._status).toBe(502); + expect(JSON.parse(res._body)).toEqual({ + success: false, + error: 'MCP invoke failed: ThrottlingException', + }); + }); + + it('passes targetName to resolveInvokeTarget', async () => { + mockedResolve.mockResolvedValue({ + success: false, + error: new Error('not found'), + }); + + const ctx = mockDeployedCtx( + {}, + JSON.stringify({ agentName: 'a', targetName: 'prod', body: { method: 'initialize' } }) + ); + const req = mockReq('/api/mcp?target=deployed'); + const res = mockRes(); + + await handleMcpProxy(ctx, req, res, undefined); + + expect(mockedResolve).toHaveBeenCalledWith(expect.objectContaining({ targetName: 'prod' })); + }); +}); From 608bfdad50abda28a1f02fea9f002cbc838ddde9 Mon Sep 17 00:00:00 2001 From: Avi Alpert Date: Thu, 21 May 2026 16:48:58 -0400 Subject: [PATCH 5/7] fix: consolidate test mocks --- .../dev/web-ui/__tests__/mcp-proxy.test.ts | 91 ++++--------------- 1 file changed, 17 insertions(+), 74 deletions(-) diff --git a/src/cli/operations/dev/web-ui/__tests__/mcp-proxy.test.ts b/src/cli/operations/dev/web-ui/__tests__/mcp-proxy.test.ts index c325d1be8..6b1107412 100644 --- a/src/cli/operations/dev/web-ui/__tests__/mcp-proxy.test.ts +++ b/src/cli/operations/dev/web-ui/__tests__/mcp-proxy.test.ts @@ -226,9 +226,20 @@ describe('handleMcpProxy', () => { }); describe('handleMcpProxy ?target=deployed', () => { + const resolvedSuccess = { + success: true as const, + agentSpec: { name: 'mcp-agent', protocol: 'MCP' } as any, + targetName: 'default', + targetConfig: { name: 'default', region: 'us-east-1', account: '123' } as any, + region: 'us-east-1', + runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-1', + bearerToken: 'tok-1', + }; + beforeEach(() => { vi.clearAllMocks(); mockConfigIO(); + mockedResolve.mockResolvedValue(resolvedSuccess); }); it('returns 404 when no configRoot', async () => { @@ -265,10 +276,7 @@ describe('handleMcpProxy ?target=deployed', () => { }); it('returns 400 when resolveInvokeTarget fails', async () => { - mockedResolve.mockResolvedValue({ - success: false, - error: new Error('Agent not deployed'), - }); + mockedResolve.mockResolvedValue({ success: false, error: new Error('Agent not deployed') }); const ctx = mockDeployedCtx({}, JSON.stringify({ agentName: 'missing', body: { method: 'initialize' } })); const req = mockReq('/api/mcp?target=deployed'); @@ -295,15 +303,6 @@ describe('handleMcpProxy ?target=deployed', () => { }); it('handles initialize method and returns sessionId', async () => { - mockedResolve.mockResolvedValue({ - success: true, - agentSpec: { name: 'mcp-agent', protocol: 'MCP' } as any, - targetName: 'default', - targetConfig: { name: 'default', region: 'us-east-1', account: '123' } as any, - region: 'us-east-1', - runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-1', - bearerToken: 'tok-1', - }); mockedMcpInitSession.mockResolvedValue('mcp-session-abc'); const ctx = mockDeployedCtx({}, JSON.stringify({ agentName: 'mcp-agent', body: { method: 'initialize' } })); @@ -313,8 +312,7 @@ describe('handleMcpProxy ?target=deployed', () => { await handleMcpProxy(ctx, req, res, undefined); expect(res._status).toBe(200); - const parsed = JSON.parse(res._body); - expect(parsed).toEqual({ + expect(JSON.parse(res._body)).toEqual({ success: true, result: { jsonrpc: '2.0', result: {} }, sessionId: 'mcp-session-abc', @@ -322,15 +320,6 @@ describe('handleMcpProxy ?target=deployed', () => { }); it('handles tools/list method', async () => { - mockedResolve.mockResolvedValue({ - success: true, - agentSpec: { name: 'mcp-agent', protocol: 'MCP' } as any, - targetName: 'default', - targetConfig: { name: 'default', region: 'us-east-1', account: '123' } as any, - region: 'us-east-1', - runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-1', - bearerToken: 'tok-1', - }); const toolsResult = { tools: [{ name: 'get_weather', description: 'Get weather' }] }; mockedMcpListTools.mockResolvedValue(toolsResult as any); @@ -344,8 +333,7 @@ describe('handleMcpProxy ?target=deployed', () => { await handleMcpProxy(ctx, req, res, undefined); expect(res._status).toBe(200); - const parsed = JSON.parse(res._body); - expect(parsed).toEqual({ + expect(JSON.parse(res._body)).toEqual({ success: true, result: { jsonrpc: '2.0', result: toolsResult }, }); @@ -360,15 +348,6 @@ describe('handleMcpProxy ?target=deployed', () => { }); it('handles tools/call method', async () => { - mockedResolve.mockResolvedValue({ - success: true, - agentSpec: { name: 'mcp-agent', protocol: 'MCP' } as any, - targetName: 'default', - targetConfig: { name: 'default', region: 'us-east-1', account: '123' } as any, - region: 'us-east-1', - runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-1', - bearerToken: 'tok-1', - }); mockedMcpCallTool.mockResolvedValue('sunny, 72F'); const ctx = mockDeployedCtx( @@ -385,8 +364,7 @@ describe('handleMcpProxy ?target=deployed', () => { await handleMcpProxy(ctx, req, res, undefined); expect(res._status).toBe(200); - const parsed = JSON.parse(res._body); - expect(parsed).toEqual({ + expect(JSON.parse(res._body)).toEqual({ success: true, result: { jsonrpc: '2.0', result: { content: [{ type: 'text', text: 'sunny, 72F' }] } }, }); @@ -396,22 +374,9 @@ describe('handleMcpProxy ?target=deployed', () => { }); it('returns 400 when tools/call is missing params.name', async () => { - mockedResolve.mockResolvedValue({ - success: true, - agentSpec: { name: 'mcp-agent', protocol: 'MCP' } as any, - targetName: 'default', - targetConfig: { name: 'default', region: 'us-east-1', account: '123' } as any, - region: 'us-east-1', - runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-1', - bearerToken: 'tok-1', - }); - const ctx = mockDeployedCtx( {}, - JSON.stringify({ - agentName: 'mcp-agent', - body: { method: 'tools/call', params: {} }, - }) + JSON.stringify({ agentName: 'mcp-agent', body: { method: 'tools/call', params: {} } }) ); const req = mockReq('/api/mcp?target=deployed'); const res = mockRes(); @@ -423,16 +388,6 @@ describe('handleMcpProxy ?target=deployed', () => { }); it('returns 400 for unsupported method', async () => { - mockedResolve.mockResolvedValue({ - success: true, - agentSpec: { name: 'mcp-agent', protocol: 'MCP' } as any, - targetName: 'default', - targetConfig: { name: 'default', region: 'us-east-1', account: '123' } as any, - region: 'us-east-1', - runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-1', - bearerToken: 'tok-1', - }); - const ctx = mockDeployedCtx({}, JSON.stringify({ agentName: 'mcp-agent', body: { method: 'resources/list' } })); const req = mockReq('/api/mcp?target=deployed'); const res = mockRes(); @@ -444,15 +399,6 @@ describe('handleMcpProxy ?target=deployed', () => { }); it('returns 502 when AWS SDK call throws', async () => { - mockedResolve.mockResolvedValue({ - success: true, - agentSpec: { name: 'mcp-agent', protocol: 'MCP' } as any, - targetName: 'default', - targetConfig: { name: 'default', region: 'us-east-1', account: '123' } as any, - region: 'us-east-1', - runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-1', - bearerToken: 'tok-1', - }); mockedMcpListTools.mockRejectedValue(new Error('ThrottlingException')); const ctx = mockDeployedCtx({}, JSON.stringify({ agentName: 'mcp-agent', body: { method: 'tools/list' } })); @@ -469,10 +415,7 @@ describe('handleMcpProxy ?target=deployed', () => { }); it('passes targetName to resolveInvokeTarget', async () => { - mockedResolve.mockResolvedValue({ - success: false, - error: new Error('not found'), - }); + mockedResolve.mockResolvedValue({ success: false, error: new Error('not found') }); const ctx = mockDeployedCtx( {}, From 29cf24fad00e9fbc6cfaa271eadc7925970a89e4 Mon Sep 17 00:00:00 2001 From: Avi Alpert Date: Thu, 21 May 2026 17:17:13 -0400 Subject: [PATCH 6/7] fix: mcp --- .../operations/dev/web-ui/__tests__/mcp-proxy.test.ts | 10 ++++++---- src/cli/operations/dev/web-ui/handlers/mcp-proxy.ts | 4 ++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/cli/operations/dev/web-ui/__tests__/mcp-proxy.test.ts b/src/cli/operations/dev/web-ui/__tests__/mcp-proxy.test.ts index 6b1107412..5d7dbc830 100644 --- a/src/cli/operations/dev/web-ui/__tests__/mcp-proxy.test.ts +++ b/src/cli/operations/dev/web-ui/__tests__/mcp-proxy.test.ts @@ -319,8 +319,8 @@ describe('handleMcpProxy ?target=deployed', () => { }); }); - it('handles tools/list method', async () => { - const toolsResult = { tools: [{ name: 'get_weather', description: 'Get weather' }] }; + it('handles tools/list method and excludes mcpSessionId from result', async () => { + const toolsResult = { tools: [{ name: 'get_weather', description: 'Get weather' }], mcpSessionId: 'internal-sess' }; mockedMcpListTools.mockResolvedValue(toolsResult as any); const ctx = mockDeployedCtx( @@ -333,10 +333,12 @@ describe('handleMcpProxy ?target=deployed', () => { await handleMcpProxy(ctx, req, res, undefined); expect(res._status).toBe(200); - expect(JSON.parse(res._body)).toEqual({ + const parsed = JSON.parse(res._body); + expect(parsed).toEqual({ success: true, - result: { jsonrpc: '2.0', result: toolsResult }, + result: { jsonrpc: '2.0', result: { tools: toolsResult.tools } }, }); + expect(parsed.result.result).not.toHaveProperty('mcpSessionId'); expect(mockedMcpListTools).toHaveBeenCalledWith( expect.objectContaining({ region: 'us-east-1', diff --git a/src/cli/operations/dev/web-ui/handlers/mcp-proxy.ts b/src/cli/operations/dev/web-ui/handlers/mcp-proxy.ts index 9d50928bc..bc894130f 100644 --- a/src/cli/operations/dev/web-ui/handlers/mcp-proxy.ts +++ b/src/cli/operations/dev/web-ui/handlers/mcp-proxy.ts @@ -181,10 +181,10 @@ async function handleDeployedMcpProxy( res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: true, result: { jsonrpc: '2.0', result: {} }, sessionId: mcpSessionId })); } else if (method === 'tools/list') { - const result = await mcpListTools(mcpOpts); + const { tools } = await mcpListTools(mcpOpts); ctx.setCorsHeaders(res, origin); res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ success: true, result: { jsonrpc: '2.0', result } })); + res.end(JSON.stringify({ success: true, result: { jsonrpc: '2.0', result: { tools } } })); } else if (method === 'tools/call') { const params = (body as { params?: { name?: string; arguments?: Record } }).params; if (!params?.name) { From 1e62ba83ea4d17463fec1c128bdbc523dc2a592f Mon Sep 17 00:00:00 2001 From: Avi Alpert Date: Fri, 22 May 2026 11:42:41 -0400 Subject: [PATCH 7/7] fix: wire in baggae --- src/cli/operations/dev/web-ui/handlers/invocations.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/cli/operations/dev/web-ui/handlers/invocations.ts b/src/cli/operations/dev/web-ui/handlers/invocations.ts index 732f2f88e..3101c7a56 100644 --- a/src/cli/operations/dev/web-ui/handlers/invocations.ts +++ b/src/cli/operations/dev/web-ui/handlers/invocations.ts @@ -461,6 +461,7 @@ async function handleDeployedInvocation( sessionId, userId, bearerToken: resolved.bearerToken, + baggage: resolved.baggage, }); } } catch (err) { @@ -484,6 +485,7 @@ interface DeployedInvokeParams { sessionId?: string; userId?: string; bearerToken?: string; + baggage?: string; } async function handleDeployedHttpInvocation( @@ -499,6 +501,7 @@ async function handleDeployedHttpInvocation( sessionId: params.sessionId, userId: params.userId, bearerToken: params.bearerToken, + baggage: params.baggage, }); ctx.setCorsHeaders(res, origin);