Skip to content

Commit fc6dc7a

Browse files
committed
fix: restore MCP/A2A code lost during rebase conflict resolution, fix dev tests
1 parent cf0ed98 commit fc6dc7a

2 files changed

Lines changed: 375 additions & 0 deletions

File tree

src/cli/aws/agentcore.ts

Lines changed: 369 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,375 @@ export async function evaluate(options: EvaluateOptions): Promise<EvaluateResult
339339
};
340340
}
341341

342+
// ---------------------------------------------------------------------------
343+
// MCP: JSON-RPC over InvokeAgentRuntime
344+
// ---------------------------------------------------------------------------
345+
346+
export interface McpInvokeOptions {
347+
region: string;
348+
runtimeArn: string;
349+
userId?: string;
350+
mcpSessionId?: string;
351+
logger?: SSELogger;
352+
}
353+
354+
export interface McpToolDef {
355+
name: string;
356+
description?: string;
357+
inputSchema?: Record<string, unknown>;
358+
}
359+
360+
export interface McpListToolsResult {
361+
tools: McpToolDef[];
362+
mcpSessionId?: string;
363+
}
364+
365+
let mcpRequestId = 1;
366+
367+
interface McpRpcResult {
368+
result: Record<string, unknown>;
369+
mcpSessionId?: string;
370+
error?: { message?: string; code?: number };
371+
}
372+
373+
/** Send a JSON-RPC payload through InvokeAgentRuntime and return the parsed response. */
374+
async function mcpRpcCall(options: McpInvokeOptions, body: Record<string, unknown>): Promise<McpRpcResult> {
375+
const client = new BedrockAgentCoreClient({
376+
region: options.region,
377+
credentials: getCredentialProvider(),
378+
});
379+
380+
options.logger?.logSSEEvent(`MCP request: ${JSON.stringify(body)}`);
381+
382+
const command = new InvokeAgentRuntimeCommand({
383+
agentRuntimeArn: options.runtimeArn,
384+
payload: new TextEncoder().encode(JSON.stringify(body)),
385+
contentType: 'application/json',
386+
accept: 'application/json, text/event-stream',
387+
mcpSessionId: options.mcpSessionId,
388+
mcpProtocolVersion: '2025-03-26',
389+
runtimeUserId: options.userId ?? DEFAULT_RUNTIME_USER_ID,
390+
});
391+
392+
const response = await client.send(command);
393+
394+
if (!response.response) {
395+
throw new Error('No response from AgentCore Runtime');
396+
}
397+
398+
const bytes = await response.response.transformToByteArray();
399+
const text = new TextDecoder().decode(bytes);
400+
401+
options.logger?.logSSEEvent(`MCP response: ${text}`);
402+
403+
const parsed = parseJsonRpcResponse(text);
404+
405+
return {
406+
result: (parsed.result as Record<string, unknown>) ?? {},
407+
mcpSessionId: response.mcpSessionId,
408+
error: parsed.error as McpRpcResult['error'],
409+
};
410+
}
411+
412+
/** Call mcpRpcCall and throw on JSON-RPC errors. Use mcpRpcCall directly when errors should be tolerated. */
413+
async function mcpRpcCallStrict(options: McpInvokeOptions, body: Record<string, unknown>): Promise<McpRpcResult> {
414+
const result = await mcpRpcCall(options, body);
415+
if (result.error) {
416+
throw new Error(result.error.message ?? `MCP error (code ${result.error.code})`);
417+
}
418+
return result;
419+
}
420+
421+
/** Send a JSON-RPC notification (no id, no response expected). */
422+
async function mcpRpcNotify(options: McpInvokeOptions, body: Record<string, unknown>): Promise<void> {
423+
const client = new BedrockAgentCoreClient({
424+
region: options.region,
425+
credentials: getCredentialProvider(),
426+
});
427+
428+
const command = new InvokeAgentRuntimeCommand({
429+
agentRuntimeArn: options.runtimeArn,
430+
payload: new TextEncoder().encode(JSON.stringify(body)),
431+
contentType: 'application/json',
432+
accept: 'application/json, text/event-stream',
433+
mcpSessionId: options.mcpSessionId,
434+
mcpProtocolVersion: '2025-03-26',
435+
runtimeUserId: options.userId ?? DEFAULT_RUNTIME_USER_ID,
436+
});
437+
438+
await client.send(command);
439+
}
440+
441+
/**
442+
* Initialize MCP session and list available tools via InvokeAgentRuntime.
443+
* Retries on cold-start initialization timeouts.
444+
*/
445+
export async function mcpListTools(options: McpInvokeOptions): Promise<McpListToolsResult> {
446+
const maxRetries = 3;
447+
448+
for (let attempt = 0; attempt < maxRetries; attempt++) {
449+
try {
450+
return await mcpListToolsOnce(options);
451+
} catch (err) {
452+
const msg = err instanceof Error ? err.message : String(err);
453+
const isColdStart = msg.includes('initialization time exceeded') || msg.includes('initialization');
454+
455+
if (isColdStart && attempt < maxRetries - 1) {
456+
options.logger?.logSSEEvent(`MCP cold start (attempt ${attempt + 1}/${maxRetries}), retrying...`);
457+
await new Promise(resolve => setTimeout(resolve, 2000));
458+
continue;
459+
}
460+
throw err;
461+
}
462+
}
463+
464+
throw new Error('Failed to list MCP tools after retries');
465+
}
466+
467+
async function mcpListToolsOnce(options: McpInvokeOptions): Promise<McpListToolsResult> {
468+
// 1. Initialize — tolerate JSON-RPC errors (stateless servers may reject initialize but still return a session ID)
469+
const initResult = await mcpRpcCall(options, {
470+
jsonrpc: '2.0',
471+
id: mcpRequestId++,
472+
method: 'initialize',
473+
params: {
474+
protocolVersion: '2025-03-26',
475+
capabilities: {},
476+
clientInfo: { name: 'agentcore-cli', version: '1.0.0' },
477+
},
478+
});
479+
480+
if (initResult.error) {
481+
options.logger?.logSSEEvent(
482+
`MCP initialize returned error (expected for stateless servers): ${initResult.error.message}`
483+
);
484+
}
485+
486+
const sessionId = initResult.mcpSessionId;
487+
const optionsWithSession = { ...options, mcpSessionId: sessionId };
488+
489+
// 2. Send initialized notification
490+
await mcpRpcNotify(optionsWithSession, {
491+
jsonrpc: '2.0',
492+
method: 'notifications/initialized',
493+
});
494+
495+
// 3. List tools
496+
const listResult = await mcpRpcCallStrict(optionsWithSession, {
497+
jsonrpc: '2.0',
498+
id: mcpRequestId++,
499+
method: 'tools/list',
500+
params: {},
501+
});
502+
503+
const tools = (listResult.result as { tools?: McpToolDef[] }).tools ?? [];
504+
505+
return {
506+
tools: tools.map(t => ({ name: t.name, description: t.description, inputSchema: t.inputSchema })),
507+
mcpSessionId: sessionId,
508+
};
509+
}
510+
511+
/**
512+
* Initialize an MCP session (without listing tools).
513+
* Returns just the session ID needed for subsequent tool calls.
514+
*/
515+
export async function mcpInitSession(options: McpInvokeOptions): Promise<string | undefined> {
516+
const initResult = await mcpRpcCall(options, {
517+
jsonrpc: '2.0',
518+
id: mcpRequestId++,
519+
method: 'initialize',
520+
params: {
521+
protocolVersion: '2025-03-26',
522+
capabilities: {},
523+
clientInfo: { name: 'agentcore-cli', version: '1.0.0' },
524+
},
525+
});
526+
527+
const sessionId = initResult.mcpSessionId;
528+
const optionsWithSession = { ...options, mcpSessionId: sessionId };
529+
530+
await mcpRpcNotify(optionsWithSession, {
531+
jsonrpc: '2.0',
532+
method: 'notifications/initialized',
533+
});
534+
535+
return sessionId;
536+
}
537+
538+
/**
539+
* Call an MCP tool via InvokeAgentRuntime.
540+
* Retries on cold-start initialization timeouts.
541+
*/
542+
export async function mcpCallTool(
543+
options: McpInvokeOptions,
544+
toolName: string,
545+
args: Record<string, unknown>
546+
): Promise<string> {
547+
const maxRetries = 3;
548+
549+
for (let attempt = 0; attempt < maxRetries; attempt++) {
550+
try {
551+
const { result } = await mcpRpcCallStrict(options, {
552+
jsonrpc: '2.0',
553+
id: mcpRequestId++,
554+
method: 'tools/call',
555+
params: { name: toolName, arguments: args },
556+
});
557+
558+
const content = (result as { content?: { type?: string; text?: string }[] }).content;
559+
if (content) {
560+
const texts: string[] = [];
561+
for (const item of content) {
562+
if (item.text !== undefined) {
563+
texts.push(item.text);
564+
}
565+
}
566+
if (texts.length > 0) return texts.join('');
567+
}
568+
569+
return JSON.stringify(result, null, 2);
570+
} catch (err) {
571+
const msg = err instanceof Error ? err.message : String(err);
572+
const isColdStart = msg.includes('initialization time exceeded') || msg.includes('initialization');
573+
574+
if (isColdStart && attempt < maxRetries - 1) {
575+
options.logger?.logSSEEvent(`MCP cold start (attempt ${attempt + 1}/${maxRetries}), retrying...`);
576+
await new Promise(resolve => setTimeout(resolve, 2000));
577+
continue;
578+
}
579+
throw err;
580+
}
581+
}
582+
583+
throw new Error('Failed to call MCP tool after retries');
584+
}
585+
586+
// ---------------------------------------------------------------------------
587+
// A2A: JSON-RPC message/send over InvokeAgentRuntime
588+
// ---------------------------------------------------------------------------
589+
590+
export interface A2AInvokeOptions {
591+
region: string;
592+
runtimeArn: string;
593+
userId?: string;
594+
logger?: SSELogger;
595+
}
596+
597+
let a2aRequestId = 1;
598+
599+
/**
600+
* Invoke a deployed A2A agent via InvokeAgentRuntime with JSON-RPC message/send.
601+
* Streams text parts from the response artifacts.
602+
*/
603+
export async function invokeA2ARuntime(options: A2AInvokeOptions, message: string): Promise<StreamingInvokeResult> {
604+
const client = new BedrockAgentCoreClient({
605+
region: options.region,
606+
credentials: getCredentialProvider(),
607+
});
608+
609+
const body = {
610+
jsonrpc: '2.0',
611+
id: a2aRequestId++,
612+
method: 'message/send',
613+
params: {
614+
message: {
615+
role: 'user',
616+
parts: [{ kind: 'text', text: message }],
617+
messageId: `msg-${Date.now()}`,
618+
},
619+
},
620+
};
621+
622+
options.logger?.logSSEEvent(`A2A request: ${JSON.stringify(body)}`);
623+
624+
const command = new InvokeAgentRuntimeCommand({
625+
agentRuntimeArn: options.runtimeArn,
626+
payload: new TextEncoder().encode(JSON.stringify(body)),
627+
contentType: 'application/json',
628+
accept: 'application/json, text/event-stream',
629+
runtimeUserId: options.userId ?? DEFAULT_RUNTIME_USER_ID,
630+
});
631+
632+
const response = await client.send(command);
633+
634+
if (!response.response) {
635+
throw new Error('No response from AgentCore Runtime');
636+
}
637+
638+
const bytes = await response.response.transformToByteArray();
639+
const text = new TextDecoder().decode(bytes);
640+
641+
options.logger?.logSSEEvent(`A2A response: ${text}`);
642+
643+
const parsed = parseA2AResponse(text);
644+
645+
return {
646+
stream: singleValueStream(parsed),
647+
sessionId: undefined,
648+
};
649+
}
650+
651+
/** Wrap a single string value as an AsyncGenerator for StreamingInvokeResult compatibility. */
652+
async function* singleValueStream(value: string): AsyncGenerator<string, void, unknown> {
653+
yield await Promise.resolve(value);
654+
}
655+
656+
/** Extract text content from A2A JSON-RPC response. Supports both kind:'text' and type:'text' part formats. */
657+
export function parseA2AResponse(text: string): string {
658+
try {
659+
const parsed: unknown = JSON.parse(text);
660+
if (!parsed || typeof parsed !== 'object') return text;
661+
662+
const obj = parsed as Record<string, unknown>;
663+
664+
// Check for JSON-RPC error
665+
if (obj.error && typeof obj.error === 'object') {
666+
const err = obj.error as { message?: string };
667+
return `Error: ${err.message ?? JSON.stringify(obj.error)}`;
668+
}
669+
670+
// Extract text from result.artifacts[].parts[].text
671+
const result = obj.result as Record<string, unknown> | undefined;
672+
if (!result) return text;
673+
674+
const artifacts = result.artifacts as { parts?: { kind?: string; type?: string; text?: string }[] }[] | undefined;
675+
if (artifacts) {
676+
const texts: string[] = [];
677+
for (const artifact of artifacts) {
678+
if (artifact.parts) {
679+
for (const part of artifact.parts) {
680+
if ((part.kind === 'text' || part.type === 'text') && part.text !== undefined) {
681+
texts.push(part.text);
682+
}
683+
}
684+
}
685+
}
686+
if (texts.length > 0) return texts.join('');
687+
}
688+
689+
// Fallback: check history for the last assistant message
690+
const history = result.history as
691+
| { role?: string; parts?: { kind?: string; type?: string; text?: string }[] }[]
692+
| undefined;
693+
if (history) {
694+
for (let i = history.length - 1; i >= 0; i--) {
695+
const msg = history[i];
696+
if (msg?.role === 'agent' && msg.parts) {
697+
const agentTexts = msg.parts
698+
.filter(p => (p.kind === 'text' || p.type === 'text') && p.text !== undefined)
699+
.map(p => p.text!);
700+
if (agentTexts.length > 0) return agentTexts.join('');
701+
}
702+
}
703+
}
704+
705+
return JSON.stringify(result, null, 2);
706+
} catch {
707+
return text;
708+
}
709+
}
710+
342711
/**
343712
* Stop a runtime session.
344713
*/

0 commit comments

Comments
 (0)