Juego de Mazmorra de IA Agéntica
Módulo 2: Implementación de la API del juego y el servidor MCP de inventario
Sección titulada «Módulo 2: Implementación de la API del juego y el servidor MCP de inventario»Comenzaremos implementando nuestra Game API. Para esto, necesitamos crear 5 APIs en total:
saveGame
- crear o actualizar un juego.queryGames
- devolver una lista paginada de juegos guardados anteriormente.saveAction
- guardar una acción para un juego específico.queryActions
- devolver una lista paginada de todas las acciones relacionadas con un juego.queryInventory
- devolver una lista paginada de ítems en el inventario de un jugador.
Esquema de la API
Sección titulada «Esquema de la API»Para definir las entradas y salidas de nuestra API, crearemos nuestro esquema usando Zod en el directorio packages/game-api/src/schema
de la siguiente manera:
import { z } from 'zod';
export const ActionSchema = z.object({ playerName: z.string(), timestamp: z.iso.datetime(), role: z.enum(['assistant', 'user']), content: z.string(),});
export type IAction = z.TypeOf<typeof ActionSchema>;
import { z } from 'zod';
export const QueryInputSchema = z.object({ cursor: z.string().optional(), limit: z.number().optional().default(100),});
export const createPaginatedQueryOutput = <ItemType extends z.ZodTypeAny>( itemSchema: ItemType,) => { return z.object({ items: z.array(itemSchema), cursor: z.string().nullable(), });};
export type IQueryInput = z.TypeOf<typeof QueryInputSchema>;
import { z } from 'zod';
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>;
import { z } from 'zod';
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>;
También puedes eliminar 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»El diagrama ER para nuestra aplicación es el siguiente:

Implementaremos nuestra base de datos en DynamoDB usando la biblioteca cliente ElectroDB. Para comenzar, primero necesitamos instalar electrodb
y el cliente DynamoDB ejecutando el siguiente comando:
pnpm add -w electrodb@3.5.0 @aws-sdk/client-dynamodb@3.913.0
yarn add electrodb@3.5.0 @aws-sdk/client-dynamodb@3.913.0
npm install --legacy-peer-deps electrodb@3.5.0 @aws-sdk/client-dynamodb@3.913.0
bun install electrodb@3.5.0 @aws-sdk/client-dynamodb@3.913.0
Ahora creemos los siguientes archivos en nuestra carpeta packages/game-api/src/entities
para definir nuestras entidades ElectroDB según el diagrama ER anterior:
import { Entity } from 'electrodb';import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
export const createActionEntity = (client?: DynamoDBClient) => new Entity( { model: { entity: 'Action', version: '1', service: 'game', }, attributes: { playerName: { type: 'string', required: true, readOnly: true }, timestamp: { type: 'string', required: true, readOnly: true, set: () => new Date().toISOString(), default: () => new Date().toISOString(), }, role: { type: 'string', required: true, readOnly: true }, content: { type: 'string', required: true, readOnly: true }, }, indexes: { primary: { pk: { field: 'pk', composite: ['playerName'] }, sk: { field: 'sk', composite: ['timestamp'] }, }, }, }, { client, table: process.env.TABLE_NAME }, );
import { Entity } from 'electrodb';import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
export const createGameEntity = (client?: 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: process.env.TABLE_NAME }, );
import { Entity } from 'electrodb';import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
export const createInventoryEntity = (client?: 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: process.env.TABLE_NAME }, );
ElectroDB nos permite no solo definir nuestros tipos, sino también proporcionar valores predeterminados para ciertos campos como los timestamps anteriores. 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/inventory.js';
Añadiendo el cliente DynamoDB al contexto de tRPC
Sección titulada «Añadiendo el cliente DynamoDB al contexto de tRPC»Dado que necesitamos acceso al cliente DynamoDB en cada uno de nuestros procedimientos, queremos crear una única instancia del cliente que podamos pasar a través del contexto. Para esto, realiza los siguientes cambios en packages/game-api/src
:
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';import { initTRPC } from '@trpc/server';
export interface IDynamoDBContext { dynamoDb?: DynamoDBClient;}
export const createDynamoDBPlugin = () => { const t = initTRPC.context<IDynamoDBContext>().create(); return t.procedure.use(async (opts) => { const dynamoDb = new DynamoDBClient();
const response = await opts.next({ ctx: { ...opts.ctx, dynamoDb, }, });
return response; });};
Este es un plugin que instrumentamos para crear el DynamoDBClient
e inyectarlo en el contexto.
import { CreateAWSLambdaContextOptions } from '@trpc/server/adapters/aws-lambda';import type { APIGatewayProxyEvent } from 'aws-lambda';import { ILoggerContext } from './logger.js';import { IMetricsContext } from './metrics.js';import { ITracerContext } from './tracer.js';import { IDynamoDBContext } from './dynamodb.js';
export * from './dynamodb.js';export * from './logger.js';export * from './metrics.js';export * from './tracer.js';export * from './error.js';
export type IMiddlewareContext = CreateAWSLambdaContextOptions<APIGatewayProxyEvent> & IDynamoDBContext & ILoggerContext & IMetricsContext & ITracerContext;
Aumentamos nuestro IMiddlewareContext
para añadir el IDynamoDBContext
.
import { initTRPC } from '@trpc/server';import { createDynamoDBPlugin, createErrorPlugin, createLoggerPlugin, createMetricsPlugin, createTracerPlugin, IMiddlewareContext,} from './middleware/index.js';
process.env.POWERTOOLS_SERVICE_NAME = 'GameApi';process.env.POWERTOOLS_METRICS_NAMESPACE = 'GameApi';
export type Context = IMiddlewareContext;
export const t = initTRPC.context<Context>().create();
export const publicProcedure = t.procedure .concat(createDynamoDBPlugin()) .concat(createLoggerPlugin()) .concat(createTracerPlugin()) .concat(createMetricsPlugin()) .concat(createErrorPlugin());
El plugin de DynamoDB está instrumentado.
Definiendo nuestros procedimientos
Sección titulada «Definiendo nuestros procedimientos»Ahora implementaremos los métodos de la API. Para esto, realiza los siguientes cambios en packages/game-api/src/procedures
:
Consultas
Sección titulada «Consultas»import { createActionEntity } from '../entities/action.js';import { ActionSchema, IAction, QueryInputSchema, createPaginatedQueryOutput,} from '../schema/index.js';import { publicProcedure } from '../init.js';import { z } from 'zod';
export const queryActions = publicProcedure .input(QueryInputSchema.extend({ playerName: z.string() })) .output(createPaginatedQueryOutput(ActionSchema)) .query(async ({ input, ctx }) => { const actionEntity = createActionEntity(ctx.dynamoDb); const result = await actionEntity.query .primary({ playerName: input.playerName }) .go({ cursor: input.cursor, count: input.limit });
return { items: result.data as IAction[], cursor: result.cursor, }; });
import { createGameEntity } from '../entities/game.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, ctx }) => { const gameEntity = createGameEntity(ctx.dynamoDb); const result = await gameEntity.scan.go({ cursor: input.cursor, count: input.limit, });
return { items: result.data as IGame[], cursor: result.cursor, }; });
import { ItemSchema, QueryInputSchema, createPaginatedQueryOutput,} from '../schema/index.js';import { publicProcedure } from '../init.js';import { z } from 'zod';import { createInventoryEntity } from '../entities/inventory.js';
export const queryInventory = publicProcedure .input(QueryInputSchema.extend({ playerName: z.string() })) .output(createPaginatedQueryOutput(ItemSchema)) .query(async ({ input, ctx }) => { const inventoryEntity = createInventoryEntity(ctx.dynamoDb); const result = await inventoryEntity.query .primary({ playerName: input.playerName }) .go({ cursor: input.cursor, count: input.limit });
return { items: result.data, cursor: result.cursor, }; });
Mutaciones
Sección titulada «Mutaciones»import { ActionSchema, IAction } from '../schema/index.js';import { publicProcedure } from '../init.js';import { createActionEntity } from '../entities/action.js';import { createGameEntity } from '../entities/game.js';
export const saveAction = publicProcedure .input(ActionSchema.omit({ timestamp: true })) .output(ActionSchema) .mutation(async ({ input, ctx }) => { const actionEntity = createActionEntity(ctx.dynamoDb); const gameEntity = createGameEntity(ctx.dynamoDb);
const action = await actionEntity.put(input).go(); await gameEntity .update({ playerName: input.playerName }) .set({ lastUpdated: action.data.timestamp }) .go(); return action.data as IAction; });
import { createGameEntity } from '../entities/game.js';import { GameSchema, IGame } from '../schema/index.js';import { publicProcedure } from '../init.js';
export const saveGame = publicProcedure .input(GameSchema.omit({ lastUpdated: true })) .output(GameSchema) .mutation(async ({ input, ctx }) => { const gameEntity = createGameEntity(ctx.dynamoDb);
const result = await gameEntity.put(input).go(); return result.data as IGame; });
También puedes eliminar 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»Ahora que hemos definido nuestros procedimientos, conectémoslos a nuestra API. Para esto, actualiza el siguiente archivo:
import { awsLambdaRequestHandler, CreateAWSLambdaContextOptions,} from '@trpc/server/adapters/aws-lambda';import { t } from './init.js';import { APIGatewayProxyEvent } from 'aws-lambda';import { queryActions } from './procedures/query-actions.js';import { saveAction } from './procedures/save-action.js';import { queryGames } from './procedures/query-games.js';import { saveGame } from './procedures/save-game.js';import { queryInventory } from './procedures/query-inventory.js';
export const router = t.router;
export const appRouter = router({ actions: router({ query: queryActions, save: saveAction, }), games: router({ query: queryGames, save: saveGame, }), inventory: router({ query: queryInventory, }),});
export const handler = awsLambdaRequestHandler({ router: appRouter, createContext: ( ctx: CreateAWSLambdaContextOptions<APIGatewayProxyEvent>, ) => ctx, responseMeta: () => ({ headers: { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': '*', }, }),});
export type AppRouter = typeof appRouter;
import { awsLambdaRequestHandler, CreateAWSLambdaContextOptions,} from '@trpc/server/adapters/aws-lambda';import { echo } from './procedures/echo.js';import { t } from './init.js';import { APIGatewayProxyEvent } from 'aws-lambda';import { queryActions } from './procedures/query-actions.js';import { saveAction } from './procedures/save-action.js';import { queryGames } from './procedures/query-games.js';import { saveGame } from './procedures/save-game.js';import { queryInventory } from './procedures/query-inventory.js';
export const router = t.router;
export const appRouter = router({ echo, actions: router({ query: queryActions, save: saveAction, }), games: router({ query: queryGames, save: saveGame, }), inventory: router({ query: queryInventory, }),});
export const handler = awsLambdaRequestHandler({ router: appRouter, createContext: ( ctx: CreateAWSLambdaContextOptions<APIGatewayProxyEvent>, ) => ctx, responseMeta: () => ({ headers: { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': '*', }, }),});
export type AppRouter = typeof appRouter;
Servidor MCP de inventario
Sección titulada «Servidor MCP de inventario»Ahora 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-items
para obtener los ítems actuales del inventario del jugadoradd-to-inventory
para añadir ítems al inventario del jugadorremove-from-inventory
para eliminar ítems del inventario del jugador
Para ahorrar tiempo, definiremos todas las herramientas inline:
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';import z from 'zod-v3';import { createInventoryEntity } from ':dungeon-adventure/game-api';
/** * Create the MCP Server */export const createServer = () => { const server = new McpServer({ name: 'inventory-mcp-server', version: '1.0.0', });
const dynamoDb = new DynamoDBClient(); const inventory = createInventoryEntity(dynamoDb);
server.tool( 'list-inventory-items', "List items in the player's inventory. Leave cursor blank unless you are requesting subsequent pages", { playerName: z.string(), cursor: z.string().optional(), }, async ({ playerName }) => { const results = await inventory.query .primary({ playerName, }) .go();
return { content: [{ type: 'text', text: JSON.stringify(results) }], }; }, );
server.tool( 'add-to-inventory', "Add an item to the player's inventory. Quantity defaults to 1 if omitted.", { playerName: z.string(), itemName: z.string(), emoji: z.string(), quantity: z.number().optional().default(1), }, async ({ playerName, itemName, emoji, quantity = 1 }) => { await inventory .put({ playerName, itemName, quantity, emoji, }) .go();
return { content: [ { type: 'text', text: `Added ${itemName} (x${quantity}) to inventory`, }, ], }; }, );
server.tool( 'remove-from-inventory', "Remove an item from the player's inventory. If quantity is omitted, all items are removed.", { playerName: z.string(), itemName: z.string(), quantity: z.number().optional(), }, async ({ playerName, itemName, quantity }) => { // If quantity is omitted, remove the entire item if (quantity === undefined) { try { await inventory.delete({ playerName, itemName }).go(); return { content: [ { type: 'text', text: `${itemName} removed from inventory.` }, ], } as const; } catch { return { content: [ { type: 'text', text: `${itemName} not found in inventory` }, ], } as const; } }
// If quantity is specified, fetch current quantity and update const item = await inventory.get({ playerName, itemName }).go();
if (!item.data) { return { content: [ { type: 'text', text: `${itemName} not found in inventory` }, ], } as const; }
const newQuantity = item.data.quantity - quantity;
if (newQuantity <= 0) { await inventory.delete({ playerName, itemName }).go(); return { content: [ { type: 'text', text: `${itemName} removed from inventory.` }, ], } as const; }
await inventory .put({ playerName, itemName, quantity: newQuantity, emoji: item.data.emoji, }) .go();
return { content: [ { type: 'text', text: `Removed ${itemName} (x${quantity}) from inventory. ${newQuantity} remaining.`, }, ], }; }, );
return server;};
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';import { registerAddTool } from './tools/add.js';import { registerSampleGuidanceResource } from './resources/sample-guidance.js';import z from 'zod-v3';import { createInventoryEntity } from ':dungeon-adventure/game-api';
/** * Create the MCP Server */export const createServer = () => { const server = new McpServer({ name: 'inventory-mcp-server', version: '1.0.0', });
registerAddTool(server); registerSampleGuidanceResource(server); const dynamoDb = new DynamoDBClient(); const inventory = createInventoryEntity(dynamoDb);
server.tool( 'list-inventory-items', "List items in the player's inventory. Leave cursor blank unless you are requesting subsequent pages", { playerName: z.string(), cursor: z.string().optional(), }, async ({ playerName }) => { const results = await inventory.query .primary({ playerName, }) .go();
return { content: [{ type: 'text', text: JSON.stringify(results) }], }; }, );
server.tool( 'add-to-inventory', "Add an item to the player's inventory. Quantity defaults to 1 if omitted.", { playerName: z.string(), itemName: z.string(), emoji: z.string(), quantity: z.number().optional().default(1), }, async ({ playerName, itemName, emoji, quantity = 1 }) => { await inventory .put({ playerName, itemName, quantity, emoji, }) .go();
return { content: [ { type: 'text', text: `Added ${itemName} (x${quantity}) to inventory`, }, ], }; }, );
server.tool( 'remove-from-inventory', "Remove an item from the player's inventory. If quantity is omitted, all items are removed.", { playerName: z.string(), itemName: z.string(), quantity: z.number().optional(), }, async ({ playerName, itemName, quantity }) => { // If quantity is omitted, remove the entire item if (quantity === undefined) { try { await inventory.delete({ playerName, itemName }).go(); return { content: [ { type: 'text', text: `${itemName} removed from inventory.` }, ], } as const; } catch { return { content: [ { type: 'text', text: `${itemName} not found in inventory` }, ], } as const; } }
// If quantity is specified, fetch current quantity and update const item = await inventory.get({ playerName, itemName }).go();
if (!item.data) { return { content: [ { type: 'text', text: `${itemName} not found in inventory` }, ], } as const; }
const newQuantity = item.data.quantity - quantity;
if (newQuantity <= 0) { await inventory.delete({ playerName, itemName }).go(); return { content: [ { type: 'text', text: `${itemName} removed from inventory.` }, ], } as const; }
await inventory .put({ playerName, itemName, quantity: newQuantity, emoji: item.data.emoji, }) .go();
return { content: [ { type: 'text', 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.
Ahora puedes eliminar los directorios tools
y resources
en packages/inventory/src/mcp-server
ya que no se usan.
Infraestructura
Sección titulada «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,} from ':dungeon-adventure/common-constructs';import { Stack, StackProps, CfnOutput } from 'aws-cdk-lib';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 userIdentity = new UserIdentity(this, 'UserIdentity');
const electroDbTable = new ElectrodbDynamoTable(this, 'ElectroDbTable');
const gameApi = new GameApi(this, 'GameApi', { integrations: GameApi.defaultIntegrations(this) .withDefaultOptions({ environment: { TABLE_NAME: electroDbTable.tableName, }, }) .build(), });
electroDbTable.grantReadData(gameApi.integrations['actions.query'].handler); electroDbTable.grantReadData(gameApi.integrations['games.query'].handler); electroDbTable.grantReadData(gameApi.integrations['inventory.query'].handler); electroDbTable.grantReadWriteData( gameApi.integrations['actions.save'].handler, ); electroDbTable.grantReadWriteData( gameApi.integrations['games.save'].handler, );
const { userPool, userPoolClient } = userIdentity;
const mcpServer = new InventoryMcpServer(this, 'InventoryMcpServer', { environment: { TABLE_NAME: electroDbTable.tableName, }, }); electroDbTable.grantReadWriteData(mcpServer.agentCoreRuntime);
// Use Cognito for user authentication with the agent const storyAgent = new StoryAgent(this, 'StoryAgent', { authorizerConfiguration: { customJwtAuthorizer: { discoveryUrl: `https://cognito-idp.${Stack.of(userPool).region}.amazonaws.com/${userPool.userPoolId}/.well-known/openid-configuration`, allowedAudience: [userPoolClient.userPoolClientId], }, }, environment: { INVENTORY_MCP_ARN: mcpServer.agentCoreRuntime.arn, }, }); // Add the Story Agent ARN to runtime-config.json so it can be referenced by the website RuntimeConfig.ensure(this).config.agentArn = storyAgent.agentCoreRuntime.arn;
new CfnOutput(this, 'StoryAgentArn', { value: storyAgent.agentCoreRuntime.arn, }); new CfnOutput(this, 'InventoryMcpArn', { value: mcpServer.agentCoreRuntime.arn, });
// Grant the agent permissions to invoke our mcp server mcpServer.agentCoreRuntime.grantInvoke(storyAgent.agentCoreRuntime);
// Grant the authenticated role access to invoke the api gameApi.grantInvokeAccess(userIdentity.identityPool.authenticatedRole);
// Ensure this is instantiated last so our runtime-config.json can be automatically configured new GameUI(this, 'GameUI'); }}
import { GameApi, GameUI, InventoryMcpServer, RuntimeConfig, StoryAgent, UserIdentity,} from ':dungeon-adventure/common-constructs';import { Stack, StackProps, CfnOutput } from 'aws-cdk-lib';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 userIdentity = new UserIdentity(this, 'UserIdentity');
const electroDbTable = new ElectrodbDynamoTable(this, 'ElectroDbTable');
const gameApi = new GameApi(this, 'GameApi', { integrations: GameApi.defaultIntegrations(this).build(), integrations: GameApi.defaultIntegrations(this) .withDefaultOptions({ environment: { TABLE_NAME: electroDbTable.tableName, }, }) .build(), });
electroDbTable.grantReadData(gameApi.integrations['actions.query'].handler); electroDbTable.grantReadData(gameApi.integrations['games.query'].handler); electroDbTable.grantReadData(gameApi.integrations['inventory.query'].handler); electroDbTable.grantReadWriteData( gameApi.integrations['actions.save'].handler, ); electroDbTable.grantReadWriteData( gameApi.integrations['games.save'].handler, );
const { userPool, userPoolClient } = userIdentity;
const mcpServer = new InventoryMcpServer(this, 'InventoryMcpServer'); const mcpServer = new InventoryMcpServer(this, 'InventoryMcpServer', { environment: { TABLE_NAME: electroDbTable.tableName, }, }); electroDbTable.grantReadWriteData(mcpServer.agentCoreRuntime);
// Use Cognito for user authentication with the agent const storyAgent = new StoryAgent(this, 'StoryAgent', { authorizerConfiguration: { customJwtAuthorizer: { discoveryUrl: `https://cognito-idp.${Stack.of(userPool).region}.amazonaws.com/${userPool.userPoolId}/.well-known/openid-configuration`, allowedAudience: [userPoolClient.userPoolClientId], }, }, environment: { INVENTORY_MCP_ARN: mcpServer.agentCoreRuntime.arn, }, }); // Add the Story Agent ARN to runtime-config.json so it can be referenced by the website RuntimeConfig.ensure(this).config.agentArn = storyAgent.agentCoreRuntime.arn;
new CfnOutput(this, 'StoryAgentArn', { value: storyAgent.agentCoreRuntime.arn, }); new CfnOutput(this, 'InventoryMcpArn', { value: mcpServer.agentCoreRuntime.arn, });
// Grant the agent permissions to invoke our mcp server mcpServer.agentCoreRuntime.grantInvoke(storyAgent.agentCoreRuntime);
// Grant the authenticated role access to invoke the api gameApi.grantInvokeAccess(userIdentity.identityPool.authenticatedRole);
// Ensure this is instantiated last so our runtime-config.json can be automatically configured new GameUI(this, 'GameUI'); }}
Despliegue y pruebas
Sección titulada «Despliegue y pruebas»Primero, construyamos la base de código:
pnpm nx run-many --target build --all
yarn nx run-many --target build --all
npx nx run-many --target build --all
bunx nx run-many --target build --all
Ahora puedes desplegar tu aplicación ejecutando:
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-sandbox-Applicationdungeon-adventure-sandbox-Application: deploying... [2/2]
✅ dungeon-adventure-sandbox-Application
✨ Deployment time: 354s
Outputs:dungeon-adventure-sandbox-Application.ElectroDbTableTableNameXXX = dungeon-adventure-sandbox-Application-ElectroDbTableXXX-YYYdungeon-adventure-sandbox-Application.GameApiEndpointXXX = https://xxx.execute-api.region.amazonaws.com/prod/dungeon-adventure-sandbox-Application.GameUIDistributionDomainNameXXX = xxx.cloudfront.netdungeon-adventure-sandbox-Application.StoryApiEndpointXXX = https://xxx.execute-api.region.amazonaws.com/prod/dungeon-adventure-sandbox-Application.UserIdentityUserIdentityIdentityPoolIdXXX = region:xxxdungeon-adventure-sandbox-Application.UserIdentityUserIdentityUserPoolIdXXX = region_xxx
Podemos probar nuestra 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
curl con Sigv4 habilitado
Puedes agregar el siguiente script a tu archivo
.bashrc
(y hacersource
) o pegarlo directamente en la terminal donde ejecutarás 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)" "$@"}Ejemplos de uso:
API Gateway
Sección titulada «API Gateway»Ventana de terminal acurl ap-southeast-2 execute-api -X GET https://xxxURL de función Lambda
Sección titulada «URL de función Lambda»Ventana de terminal acurl ap-southeast-2 lambda -N -X POST https://xxxAgrega esta función a tu perfil de PowerShell o pégala en la sesión actual.
Ventana de terminal function 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}Ejemplos de uso:
API Gateway
Sección titulada «API Gateway»Ventana de terminal acurl ap-southeast-2 execute-api -X GET https://xxxURL de función Lambda
Sección titulada «URL de función Lambda»Ventana de terminal acurl ap-southeast-2 lambda -N -X POST https://xxx
Inicia tu servidor local game-api
ejecutando:
TABLE_NAME=dungeon-adventure-infra-sandbox-Application-ElectroDbTableXXX-YYY pnpm nx run @dungeon-adventure/game-api:serve
TABLE_NAME=dungeon-adventure-infra-sandbox-Application-ElectroDbTableXXX-YYY yarn nx run @dungeon-adventure/game-api:serve
TABLE_NAME=dungeon-adventure-infra-sandbox-Application-ElectroDbTableXXX-YYY npx nx run @dungeon-adventure/game-api:serve
TABLE_NAME=dungeon-adventure-infra-sandbox-Application-ElectroDbTableXXX-YYY bunx nx run @dungeon-adventure/game-api:serve
Una vez iniciado el servidor, puedes llamarlo con:
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! 🎉🎉🎉