Implementar la API del Juego y el servidor MCP de Inventario
Tarea 1: Implementar la Game API
Sección titulada «Tarea 1: Implementar la Game API»Implementaremos las siguientes APIs en esta sección:
saveGame- crear o actualizar un juego.queryGames- devolver una lista paginada de juegos guardados anteriormente.queryInventory- devolver una lista paginada de ítems en el inventario de un jugador.queryActions- devolver el historial de conversación para un juego dado.
Esquema de la API
Sección titulada «Esquema de la API»Para definir las entradas y salidas de nuestra API, creemos nuestro esquema usando Zod en el archivo packages/game-api/src/schema/index.ts de la siguiente manera:
import { z } from 'zod';
export const QueryInputSchema = z.object({ cursor: z.string().optional(), limit: z.number().optional().default(100),});export type IQueryInput = z.TypeOf<typeof QueryInputSchema>;
export const ActionSchema = z.object({ role: z.enum(['user', 'assistant']), content: z.string(), messageId: z.number(),});export type IAction = z.TypeOf<typeof ActionSchema>;
export const GameSchema = z.object({ playerName: z.string(), genre: z.enum(['zombie', 'superhero', 'medieval']), lastUpdated: z.iso.datetime(),});export type IGame = z.TypeOf<typeof GameSchema>;
export const ItemSchema = z.object({ playerName: z.string(), itemName: z.string(), emoji: z.string().optional(), lastUpdated: z.iso.datetime(), quantity: z.number(),});export type IItem = z.TypeOf<typeof ItemSchema>;
export const createPaginatedQueryOutput = <ItemType extends z.ZodTypeAny>( itemSchema: ItemType,) => { return z.object({ items: z.array(itemSchema), cursor: z.string().nullable(), });};export * from './echo.js'import { z } from 'zod';
export const QueryInputSchema = z.object({ cursor: z.string().optional(), limit: z.number().optional().default(100),});export type IQueryInput = z.TypeOf<typeof QueryInputSchema>;
export const ActionSchema = z.object({ role: z.enum(['user', 'assistant']), content: z.string(), messageId: z.number(),});export type IAction = z.TypeOf<typeof ActionSchema>;
export const GameSchema = z.object({ playerName: z.string(), genre: z.enum(['zombie', 'superhero', 'medieval']), lastUpdated: z.iso.datetime(),});export type IGame = z.TypeOf<typeof GameSchema>;
export const ItemSchema = z.object({ playerName: z.string(), itemName: z.string(), emoji: z.string().optional(), lastUpdated: z.iso.datetime(), quantity: z.number(),});export type IItem = z.TypeOf<typeof ItemSchema>;
export const createPaginatedQueryOutput = <ItemType extends z.ZodTypeAny>( itemSchema: ItemType,) => { return z.object({ items: z.array(itemSchema), cursor: z.string().nullable(), });};Elimina el archivo packages/game-api/src/schema/echo.ts ya que no lo usaremos en este proyecto.
Modelado de entidades
Sección titulada «Modelado de entidades»Este es el diagrama ER para nuestra aplicación.
Implementaremos nuestra base de datos en DynamoDB y usaremos la biblioteca cliente ElectroDB para simplificar las cosas. El queryActions de la Game API también necesita el cliente S3 para leer los turnos de conversación que el agente ha escrito. Instálalos todos con:
pnpm add -w electrodb@3.7.5 @aws-sdk/client-dynamodb@3.1053.0 @aws-sdk/client-s3@3.1053.0yarn add electrodb@3.7.5 @aws-sdk/client-dynamodb@3.1053.0 @aws-sdk/client-s3@3.1053.0npm install --legacy-peer-deps electrodb@3.7.5 @aws-sdk/client-dynamodb@3.1053.0 @aws-sdk/client-s3@3.1053.0bun install electrodb@3.7.5 @aws-sdk/client-dynamodb@3.1053.0 @aws-sdk/client-s3@3.1053.0Para definir nuestras entidades ElectroDB a partir del diagrama ER, creemos el archivo packages/game-api/src/entities/index.ts:
import { Entity } from 'electrodb';import { DynamoDBClient } from '@aws-sdk/client-dynamodb';import { getAppConfig } from '@aws-lambda-powertools/parameters/appconfig';
/** * Resolves the DynamoDB table name from runtime config (AppConfig). */const resolveTableName = async (): Promise<string> => { const tablesConfig = await getAppConfig('tables', { application: process.env.RUNTIME_CONFIG_APP_ID!, environment: 'default', transform: 'json', });
const tableName = (tablesConfig as Record<string, any>).ElectroDbTable?.tableName; if (!tableName) { throw new Error( 'Could not resolve table name from runtime config', ); } return tableName;};
export const createGameEntity = async (client: DynamoDBClient = new DynamoDBClient()) => new Entity( { model: { entity: 'Game', version: '1', service: 'game', }, attributes: { playerName: { type: 'string', required: true, readOnly: true }, genre: { type: 'string', required: true, readOnly: true }, lastUpdated: { type: 'string', required: true, default: () => new Date().toISOString(), }, }, indexes: { primary: { pk: { field: 'pk', composite: ['playerName'] }, sk: { field: 'sk', composite: [], }, }, }, }, { client, table: await resolveTableName() }, );
export const createInventoryEntity = async (client: DynamoDBClient = new DynamoDBClient()) => new Entity( { model: { entity: 'Inventory', version: '1', service: 'game', }, attributes: { playerName: { type: 'string', required: true, readOnly: true }, lastUpdated: { type: 'string', required: true, default: () => new Date().toISOString(), }, itemName: { type: 'string', required: true, }, emoji: { type: 'string', required: false, }, quantity: { type: 'number', required: true, }, }, indexes: { primary: { pk: { field: 'pk', composite: ['playerName'] }, sk: { field: 'sk', composite: ['itemName'] }, }, }, }, { client, table: await resolveTableName() }, );ElectroDB nos permite no solo definir nuestros tipos, sino también proporcionar valores predeterminados para ciertos campos como timestamps. Además, ElectroDB sigue el diseño de tabla única, que es la mejor práctica al usar DynamoDB.
Para preparar la interacción del servidor MCP con el inventario, asegurémonos de exportar la entidad de inventario en packages/game-api/src/index.ts:
export type { AppRouter } from './router.js';export { appRouter } from './router.js';export type { Context } from './init.js';export * from './client/index.js';export * from './schema/index.js';export * from './entities/index.js';Definiendo nuestros procedimientos
Sección titulada «Definiendo nuestros procedimientos»Para implementar los métodos de la API, realiza los siguientes cambios en packages/game-api/src/procedures:
import { createGameEntity } from '../entities/index.js';import { GameSchema, IGame, QueryInputSchema, createPaginatedQueryOutput,} from '../schema/index.js';import { publicProcedure } from '../init.js';
export const queryGames = publicProcedure .input(QueryInputSchema) .output(createPaginatedQueryOutput(GameSchema)) .query(async ({ input }) => { const gameEntity = await createGameEntity(); const result = await gameEntity.scan.go({ cursor: input.cursor, count: input.limit, });
return { items: result.data as IGame[], cursor: result.cursor, }; });
export const saveGame = publicProcedure .input(GameSchema.omit({ lastUpdated: true })) .output(GameSchema) .mutation(async ({ input }) => { const gameEntity = await createGameEntity();
const result = await gameEntity.put(input).go(); return result.data as IGame; });import { ItemSchema, QueryInputSchema, createPaginatedQueryOutput,} from '../schema/index.js';import { publicProcedure } from '../init.js';import { z } from 'zod';import { createInventoryEntity } from '../entities/index.js';
export const queryInventory = publicProcedure .input(QueryInputSchema.extend({ playerName: z.string() })) .output(createPaginatedQueryOutput(ItemSchema)) .query(async ({ input }) => { const inventoryEntity = await createInventoryEntity(); const result = await inventoryEntity.query .primary({ playerName: input.playerName }) .go({ cursor: input.cursor, count: input.limit });
return { items: result.data, cursor: result.cursor, }; });import { publicProcedure } from '../init.js';import { ActionSchema, IAction } from '../schema/index.js';import { z } from 'zod';import { S3Client, ListObjectsV2Command, GetObjectCommand,} from '@aws-sdk/client-s3';import { getAppConfig } from '@aws-lambda-powertools/parameters/appconfig';import { readFile, readdir } from 'node:fs/promises';import { join, resolve } from 'node:path';
const resolveSessionsBucket = async (): Promise<string> => { const buckets = await getAppConfig('buckets', { application: process.env.RUNTIME_CONFIG_APP_ID!, environment: 'default', transform: 'json', }); const bucket = (buckets as Record<string, any>).StorySessions?.bucketName; if (!bucket) throw new Error('StorySessions bucket not found in runtime config'); return bucket;};
const s3 = new S3Client();const LOCAL_SESSION_STORAGE_DIR = '/tmp/strands-sessions';
// Matches ``session_<sessionId>/agents/agent_<agentId>/messages/message_<idx>.json``// written by ``strands.session.S3SessionManager`` on the agent side. We only// ever care about the default agent id ``default`` that Strands uses when// none is set explicitly, so hard-code the path prefix the UI needs to list.const messagesPrefix = (sessionId: string) => `session_${sessionId}/agents/agent_default/messages/`;
const messageIndex = (pathOrKey: string): number => { const match = pathOrKey.match(/message_(\d+)\.json$/); return match ? Number(match[1]) : Number.MAX_SAFE_INTEGER;};
const toAction = (body: any): IAction | undefined => { const message = body.redact_message ?? body.message; const role = message?.role; if (role !== 'user' && role !== 'assistant') return undefined; const text = Array.isArray(message.content) ? message.content .filter((b: any) => typeof b?.text === 'string') .map((b: any) => b.text) .join('') : String(message.content ?? ''); if (!text) return undefined; return { role, content: text, messageId: body.message_id };};
const readLocalActions = async (sessionId: string): Promise<IAction[]> => { const baseDir = resolve(LOCAL_SESSION_STORAGE_DIR); const messagesDir = resolve(baseDir, messagesPrefix(sessionId)); if (!messagesDir.startsWith(`${baseDir}/`)) { throw new Error('Invalid session id'); }
let files: string[]; try { files = await readdir(messagesDir); } catch (error: any) { if (error?.code === 'ENOENT') return []; throw error; }
const actions: IAction[] = []; for (const file of files .filter((f) => f.endsWith('.json')) .sort((a, b) => messageIndex(a) - messageIndex(b))) { const body = JSON.parse(await readFile(join(messagesDir, file), 'utf8')); const action = toAction(body); if (action) actions.push(action); } return actions;};
const readS3Actions = async (sessionId: string): Promise<IAction[]> => { const bucket = await resolveSessionsBucket(); const list = await s3.send( new ListObjectsV2Command({ Bucket: bucket, Prefix: messagesPrefix(sessionId), }), ); const keys = (list.Contents ?? []) .map((o) => o.Key!) .filter((k) => k.endsWith('.json')) .sort((a, b) => messageIndex(a) - messageIndex(b));
const actions: IAction[] = []; for (const key of keys) { const obj = await s3.send( new GetObjectCommand({ Bucket: bucket, Key: key }), ); const body = JSON.parse(await obj.Body!.transformToString()); const action = toAction(body); if (action) actions.push(action); } return actions;};
export const queryActions = publicProcedure .input(z.object({ sessionId: z.string() })) .output(z.object({ items: z.array(ActionSchema) })) .query(async ({ input }) => { const actions = process.env.SERVE_LOCAL === 'true' ? await readLocalActions(input.sessionId) : await readS3Actions(input.sessionId); return { items: actions }; });Elimina el archivo echo.ts (de packages/game-api/src/procedures) ya que no lo usaremos en este proyecto.
Configuración del router
Sección titulada «Configuración del router»Después de definir nuestros procedimientos, para conectarlos a nuestra API, actualiza el siguiente archivo:
import { t } from './init.js';import { queryActions } from './procedures/actions.js';import { queryGames, saveGame } from './procedures/games.js';import { queryInventory } from './procedures/inventory.js';
export const router = t.router;
export const appRouter = router({ actions: router({ query: queryActions, }), games: router({ query: queryGames, save: saveGame, }), inventory: router({ query: queryInventory, }),});
export type AppRouter = typeof appRouter;import { echo } from './procedures/echo.js';import { t } from './init.js';import { queryActions } from './procedures/actions.js';import { queryGames, saveGame } from './procedures/games.js';import { queryInventory } from './procedures/inventory.js';
export const router = t.router;
export const appRouter = router({ echo, actions: router({ query: queryActions, }), games: router({ query: queryGames, save: saveGame, }), inventory: router({ query: queryInventory, }),});
export type AppRouter = typeof appRouter;Tarea 2: Crear un servidor MCP de inventario
Sección titulada «Tarea 2: Crear un servidor MCP de inventario»Creemos un servidor MCP que permita a nuestro agente gestionar los ítems en el inventario de un jugador.
Definiremos las siguientes herramientas para nuestro agente:
list-inventory-itemspara obtener los ítems actuales del inventario del jugadoradd-to-inventorypara añadir ítems al inventario del jugadorremove-from-inventorypara eliminar ítems del inventario del jugador
Para ahorrar tiempo, definiremos todas las herramientas inline:
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';import z from 'zod';import { createInventoryEntity } from ':dungeon-adventure/game-api';
/** * Create the MCP Server */export const createServer = async () => { const server = new McpServer({ name: 'inventory-mcp-server', version: '1.0.0', });
server.registerTool( 'list-inventory-items', { description: "List items in the player's inventory. Leave cursor blank unless you are requesting subsequent pages", inputSchema: { playerName: z.string(), cursor: z.string().optional(), }, }, async ({ playerName }) => { const inventory = await createInventoryEntity(); const results = await inventory.query .primary({ playerName, }) .go();
return { content: [{ type: 'text' as const, text: JSON.stringify(results) }], }; }, );
server.registerTool( 'add-to-inventory', { description: "Add an item to the player's inventory. Quantity defaults to 1 if omitted.", inputSchema: { playerName: z.string(), itemName: z.string(), emoji: z.string(), quantity: z.number().optional().default(1), }, }, async ({ playerName, itemName, emoji, quantity = 1 }) => { const inventory = await createInventoryEntity(); await inventory .put({ playerName, itemName, quantity, emoji, }) .go();
return { content: [ { type: 'text' as const, text: `Added ${itemName} (x${quantity}) to inventory`, }, ], }; }, );
server.registerTool( 'remove-from-inventory', { description: "Remove an item from the player's inventory. If quantity is omitted, all items are removed.", inputSchema: { playerName: z.string(), itemName: z.string(), quantity: z.number().optional(), }, }, async ({ playerName, itemName, quantity }) => { const inventory = await createInventoryEntity();
// If quantity is omitted, remove the entire item if (quantity === undefined) { try { await inventory.delete({ playerName, itemName }).go(); return { content: [ { type: 'text' as const, text: `${itemName} removed from inventory.` }, ], }; } catch { return { content: [ { type: 'text' as const, text: `${itemName} not found in inventory` }, ], }; } }
// If quantity is specified, fetch current quantity and update const item = await inventory.get({ playerName, itemName }).go();
if (!item.data) { return { content: [ { type: 'text' as const, text: `${itemName} not found in inventory` }, ], }; }
const newQuantity = item.data.quantity - quantity;
if (newQuantity <= 0) { await inventory.delete({ playerName, itemName }).go(); return { content: [ { type: 'text' as const, text: `${itemName} removed from inventory.` }, ], }; }
await inventory .put({ playerName, itemName, quantity: newQuantity, emoji: item.data.emoji, }) .go();
return { content: [ { type: 'text' as const, text: `Removed ${itemName} (x${quantity}) from inventory. ${newQuantity} remaining.`, }, ], }; }, );
return server;};import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';import { registerDivideTool } from './tools/divide.js';import { registerSampleGuidanceResource } from './resources/sample-guidance.js';import z from 'zod';import { createInventoryEntity } from ':dungeon-adventure/game-api';
/** * Create the MCP Server */export const createServer = async () => { const server = new McpServer({ name: 'inventory-mcp-server', version: '1.0.0', });
registerDivideTool(server); registerSampleGuidanceResource(server); server.registerTool( 'list-inventory-items', { description: "List items in the player's inventory. Leave cursor blank unless you are requesting subsequent pages", inputSchema: { playerName: z.string(), cursor: z.string().optional(), }, }, async ({ playerName }) => { const inventory = await createInventoryEntity(); const results = await inventory.query .primary({ playerName, }) .go();
return { content: [{ type: 'text' as const, text: JSON.stringify(results) }], }; }, );
server.registerTool( 'add-to-inventory', { description: "Add an item to the player's inventory. Quantity defaults to 1 if omitted.", inputSchema: { playerName: z.string(), itemName: z.string(), emoji: z.string(), quantity: z.number().optional().default(1), }, }, async ({ playerName, itemName, emoji, quantity = 1 }) => { const inventory = await createInventoryEntity(); await inventory .put({ playerName, itemName, quantity, emoji, }) .go();
return { content: [ { type: 'text' as const, text: `Added ${itemName} (x${quantity}) to inventory`, }, ], }; }, );
server.registerTool( 'remove-from-inventory', { description: "Remove an item from the player's inventory. If quantity is omitted, all items are removed.", inputSchema: { playerName: z.string(), itemName: z.string(), quantity: z.number().optional(), }, }, async ({ playerName, itemName, quantity }) => { const inventory = await createInventoryEntity();
// If quantity is omitted, remove the entire item if (quantity === undefined) { try { await inventory.delete({ playerName, itemName }).go(); return { content: [ { type: 'text' as const, text: `${itemName} removed from inventory.` }, ], }; } catch { return { content: [ { type: 'text' as const, text: `${itemName} not found in inventory` }, ], }; } }
// If quantity is specified, fetch current quantity and update const item = await inventory.get({ playerName, itemName }).go();
if (!item.data) { return { content: [ { type: 'text' as const, text: `${itemName} not found in inventory` }, ], }; }
const newQuantity = item.data.quantity - quantity;
if (newQuantity <= 0) { await inventory.delete({ playerName, itemName }).go(); return { content: [ { type: 'text' as const, text: `${itemName} removed from inventory.` }, ], }; }
await inventory .put({ playerName, itemName, quantity: newQuantity, emoji: item.data.emoji, }) .go();
return { content: [ { type: 'text' as const, text: `Removed ${itemName} (x${quantity}) from inventory. ${newQuantity} remaining.`, }, ], }; }, );
return server;};A medida que crece el número de herramientas, puedes refactorizarlas en archivos separados si lo prefieres.
Elimina los directorios tools y resources en packages/inventory/src/mcp-server ya que no se usarán.
Tarea 3: Actualizar la infraestructura
Sección titulada «Tarea 3: Actualizar la infraestructura»El paso final es actualizar nuestra infraestructura para crear la tabla DynamoDB y otorgar permisos para realizar operaciones desde la Game API.
Para esto, actualiza packages/infra/src como sigue:
import { CfnOutput } from 'aws-cdk-lib';import { AttributeType, BillingMode, ProjectionType, Table, TableProps,} from 'aws-cdk-lib/aws-dynamodb';import { Construct } from 'constructs';import { suppressRules } from ':dungeon-adventure/common-constructs';
export type ElectrodbDynamoTableProps = Omit< TableProps, 'partitionKey' | 'sortKey' | 'billingMode'>;
export class ElectrodbDynamoTable extends Table { constructor(scope: Construct, id: string, props?: ElectrodbDynamoTableProps) { super(scope, id, { partitionKey: { name: 'pk', type: AttributeType.STRING, }, sortKey: { name: 'sk', type: AttributeType.STRING, }, billingMode: BillingMode.PAY_PER_REQUEST, ...props, });
this.addGlobalSecondaryIndex({ indexName: 'gsi1pk-gsi1sk-index', partitionKey: { name: 'gsi1pk', type: AttributeType.STRING, }, sortKey: { name: 'gsi1sk', type: AttributeType.STRING, }, projectionType: ProjectionType.ALL, });
// Suppress checkov rules that expect a KMS customer managed key and backup to be enabled suppressRules(this, ['CKV_AWS_119', 'CKV_AWS_28'], 'No need for custom encryption or backup');
new CfnOutput(this, 'TableName', { value: this.tableName }); }}import { GameApi, GameUI, InventoryMcpServer, RuntimeConfig, StoryAgent, UserIdentity, suppressRules,} from ':dungeon-adventure/common-constructs';import { Stack, StackProps, CfnOutput, RemovalPolicy } from 'aws-cdk-lib';import { BlockPublicAccess, Bucket, BucketEncryption,} from 'aws-cdk-lib/aws-s3';import { Construct } from 'constructs';import { ElectrodbDynamoTable } from '../constructs/electrodb-table.js';
export class ApplicationStack extends Stack { constructor(scope: Construct, id: string, props?: StackProps) { super(scope, id, props);
const rc = RuntimeConfig.ensure(this);
const userIdentity = new UserIdentity(this, 'UserIdentity');
const electroDbTable = new ElectrodbDynamoTable(this, 'ElectroDbTable');
// Register the table name in runtime config 'tables' namespace rc.set('tables', 'ElectroDbTable', { tableName: electroDbTable.tableName, });
// S3 bucket for Strands conversation history. The Story Agent writes each // turn via ``S3SessionManager``; the Game API reads them back for replay. const storySessions = new Bucket(this, 'StorySessions', { encryption: BucketEncryption.S3_MANAGED, blockPublicAccess: BlockPublicAccess.BLOCK_ALL, enforceSSL: true, removalPolicy: RemovalPolicy.DESTROY, autoDeleteObjects: true, }); suppressRules( storySessions, ['CKV_AWS_18', 'CKV_AWS_21'], 'Access logging and object versioning are unnecessary for ephemeral chat transcripts', ); rc.set('buckets', 'StorySessions', { bucketName: storySessions.bucketName, });
const gameApi = new GameApi(this, 'GameApi', { integrations: GameApi.defaultIntegrations(this).build(), });
electroDbTable.grantReadData(gameApi.integrations['games.query'].handler); electroDbTable.grantReadData( gameApi.integrations['inventory.query'].handler, ); electroDbTable.grantReadWriteData( gameApi.integrations['games.save'].handler, ); storySessions.grantRead(gameApi.integrations['actions.query'].handler);
const mcpServer = new InventoryMcpServer(this, 'InventoryMcpServer'); electroDbTable.grantReadWriteData(mcpServer.agentCoreRuntime);
// Use Cognito for user authentication with the agent const storyAgent = new StoryAgent(this, 'StoryAgent', { identity: userIdentity, }); storySessions.grantReadWrite(storyAgent);
new CfnOutput(this, 'StoryAgentArn', { value: storyAgent.agentCoreRuntime.agentRuntimeArn, }); new CfnOutput(this, 'InventoryMcpArn', { value: mcpServer.agentCoreRuntime.agentRuntimeArn, });
// Grant the agent permissions to invoke our mcp server mcpServer.grantInvokeAccess(storyAgent);
// Grant the authenticated role access to invoke the api gameApi.grantInvokeAccess(userIdentity.identityPool.authenticatedRole);
new GameUI(this, 'GameUI'); }}import { GameApi, GameUI, InventoryMcpServer, RuntimeConfig, StoryAgent, UserIdentity, suppressRules,} from ':dungeon-adventure/common-constructs';import { Stack, StackProps, CfnOutput } from 'aws-cdk-lib';import { Stack, StackProps, CfnOutput, RemovalPolicy } from 'aws-cdk-lib';import { BlockPublicAccess, Bucket, BucketEncryption,} from 'aws-cdk-lib/aws-s3';import { Construct } from 'constructs';import { ElectrodbDynamoTable } from '../constructs/electrodb-table.js';
export class ApplicationStack extends Stack { constructor(scope: Construct, id: string, props?: StackProps) { super(scope, id, props);
const rc = RuntimeConfig.ensure(this);
const userIdentity = new UserIdentity(this, 'UserIdentity');
const electroDbTable = new ElectrodbDynamoTable(this, 'ElectroDbTable');
// Register the table name in runtime config 'tables' namespace rc.set('tables', 'ElectroDbTable', { tableName: electroDbTable.tableName, });
// S3 bucket for Strands conversation history. The Story Agent writes each // turn via ``S3SessionManager``; the Game API reads them back for replay. const storySessions = new Bucket(this, 'StorySessions', { encryption: BucketEncryption.S3_MANAGED, blockPublicAccess: BlockPublicAccess.BLOCK_ALL, enforceSSL: true, removalPolicy: RemovalPolicy.DESTROY, autoDeleteObjects: true, }); suppressRules( storySessions, ['CKV_AWS_18', 'CKV_AWS_21'], 'Access logging and object versioning are unnecessary for ephemeral chat transcripts', ); rc.set('buckets', 'StorySessions', { bucketName: storySessions.bucketName, });
const gameApi = new GameApi(this, 'GameApi', { integrations: GameApi.defaultIntegrations(this).build(), });
electroDbTable.grantReadData(gameApi.integrations['games.query'].handler); electroDbTable.grantReadData( gameApi.integrations['inventory.query'].handler, ); electroDbTable.grantReadWriteData( gameApi.integrations['games.save'].handler, ); storySessions.grantRead(gameApi.integrations['actions.query'].handler);
const mcpServer = new InventoryMcpServer(this, 'InventoryMcpServer'); electroDbTable.grantReadWriteData(mcpServer.agentCoreRuntime);
// Use Cognito for user authentication with the agent const storyAgent = new StoryAgent(this, 'StoryAgent', { identity: userIdentity, }); storySessions.grantReadWrite(storyAgent);
new CfnOutput(this, 'StoryAgentArn', { value: storyAgent.agentCoreRuntime.agentRuntimeArn, }); new CfnOutput(this, 'InventoryMcpArn', { value: mcpServer.agentCoreRuntime.agentRuntimeArn, });
// Grant the agent permissions to invoke our mcp server mcpServer.grantInvokeAccess(storyAgent);
// Grant the authenticated role access to invoke the api gameApi.grantInvokeAccess(userIdentity.identityPool.authenticatedRole);
new GameUI(this, 'GameUI'); }}Tarea 4: Despliegue y pruebas
Sección titulada «Tarea 4: Despliegue y pruebas»Primero, corrige cualquier problema de linting:
pnpm lintyarn lintnpm run lintbun lintLuego construye la base de código:
pnpm buildyarn buildnpm run buildbun buildDespliega tu aplicación
Sección titulada «Despliega tu aplicación»Para desplegar tu aplicación, ejecuta el siguiente comando:
pnpm nx deploy infra "dungeon-adventure-infra-sandbox/*"yarn nx deploy infra "dungeon-adventure-infra-sandbox/*"npx nx deploy infra "dungeon-adventure-infra-sandbox/*"bunx nx deploy infra "dungeon-adventure-infra-sandbox/*"Tu primer despliegue tomará aproximadamente 8 minutos. Los despliegues posteriores tomarán alrededor de 2 minutos.
Una vez completado el despliegue, verás salidas similares a las siguientes (algunos valores han sido omitidos):
dungeon-adventure-infra-sandbox-Applicationdungeon-adventure-infra-sandbox-Application: deploying... [2/2]
✅ dungeon-adventure-infra-sandbox-Application
✨ Deployment time: 354s
Outputs:dungeon-adventure-infra-sandbox-Application.ElectroDbTableTableNameXXX = dungeon-adventure-infra-sandbox-Application-ElectroDbTableXXX-YYYdungeon-adventure-infra-sandbox-Application.GameApiEndpointXXX = https://xxx.execute-api.region.amazonaws.com/prod/dungeon-adventure-infra-sandbox-Application.GameUIDistributionDomainNameXXX = xxx.cloudfront.netdungeon-adventure-infra-sandbox-Application.InventoryMcpArn = arn:aws:bedrock-agentcore:region:xxxxxxx:runtime/dungeonadventureventoryMcpServerXXXX-YYYYdungeon-adventure-infra-sandbox-Application.RuntimeConfigApplicationId = xxxxdungeon-adventure-infra-sandbox-Application.StoryAgentArn = arn:aws:bedrock-agentcore:region:xxxxxxx:runtime/dungeonadventurecationStoryAgentXXXX-YYYYdungeon-adventure-infra-sandbox-Application.UserIdentityUserIdentityIdentityPoolIdXXX = region:xxxdungeon-adventure-infra-sandbox-Application.UserIdentityUserIdentityUserPoolClientIdXXX = xxxxxxxxxxdungeon-adventure-infra-sandbox-Application.UserIdentityUserIdentityUserPoolIdXXX = region_xxxPrueba la API
Sección titulada «Prueba la API»Puedes probar la API de dos formas:
- Iniciando una instancia local del backend tRPC e invocando las APIs con
curl. - Llamar a la API desplegada usando curl con sigv4 habilitado
curl con Sigv4 habilitado
Puedes agregar el siguiente script a tu archivo
.bashrc(y ejecutarsourceen él) o pegar lo siguiente en la misma terminal en la que deseas ejecutar el comando.~/.bashrc acurl () {REGION=$1SERVICE=$2shift; shift;curl --aws-sigv4 "aws:amz:$REGION:$SERVICE" --user "$(aws configure get aws_access_key_id):$(aws configure get aws_secret_access_key)" -H "X-Amz-Security-Token: $(aws configure get aws_session_token)" "$@"}Para realizar una solicitud curl autenticada con
sigv4, invocaacurlde la siguiente manera:Ventana de terminal acurl <region> <service> <other-curl-arguments>Por ejemplo:
API Gateway
Sección titulada «API Gateway»Ventana de terminal acurl ap-southeast-2 execute-api -X GET https://xxxStreaming Lambda function url
Sección titulada «Streaming Lambda function url»Ventana de terminal acurl ap-southeast-2 lambda -N -X POST https://xxxPuedes agregar la siguiente función a tu perfil de PowerShell o pegar lo siguiente en la misma sesión de PowerShell en la que deseas ejecutar el comando.
Ventana de terminal # PowerShell profile or current sessionfunction acurl {param([Parameter(Mandatory=$true)][string]$Region,[Parameter(Mandatory=$true)][string]$Service,[Parameter(ValueFromRemainingArguments=$true)][string[]]$CurlArgs)$AccessKey = aws configure get aws_access_key_id$SecretKey = aws configure get aws_secret_access_key$SessionToken = aws configure get aws_session_token& curl --aws-sigv4 "aws:amz:$Region`:$Service" --user "$AccessKey`:$SecretKey" -H "X-Amz-Security-Token: $SessionToken" @CurlArgs}Para realizar una solicitud curl autenticada con
sigv4, invocaacurlusando estos ejemplos:API Gateway
Sección titulada «API Gateway»Ventana de terminal acurl ap-southeast-2 execute-api -X GET https://xxxStreaming Lambda function url
Sección titulada «Streaming Lambda function url»Ventana de terminal acurl ap-southeast-2 lambda -N -X POST https://xxx
Para iniciar tu servidor local game-api, ejecuta el siguiente comando:
RUNTIME_CONFIG_APP_ID=xxxx pnpm nx serve game-apiRUNTIME_CONFIG_APP_ID=xxxx yarn nx serve game-apiRUNTIME_CONFIG_APP_ID=xxxx npx nx serve game-apiRUNTIME_CONFIG_APP_ID=xxxx bunx nx serve game-apiUna vez que tu servidor esté en funcionamiento, puedes llamarlo ejecutando el siguiente comando:
curl -X GET 'http://localhost:2022/games.query?input=%7B%7D'acurl ap-southeast-2 execute-api -X GET 'https://xxx.execute-api.ap-southeast-2.amazonaws.com/prod/games.query?input=%7B%7D'Si el comando se ejecuta correctamente, verás una respuesta como:
{"result":{"data":{"items":[],"cursor":null}}}¡Felicidades, has construido y desplegado tu primera API usando tRPC! 🎉🎉🎉